mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
feat(ui): keep writes durable through temporary disconnects (#17960)
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/123/changes/paperclip/123 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - People save tasks, write documents, make decisions and send board messages. > - A disconnect can hide a response after the server accepts a write. > - Recovery must keep the original account, task and request key. > - The merged connection foundation in #15754 handles reads and shared outage state. > - This change adds bounded write recovery and explicit actions when recovery stops. ## Linked Issues or Issue Description **What happened?** Temporary failures could lose edits, report accepted writes as failures, or require people to copy and send input again. A retry could target a newly selected task or account. Restoring attachments could create a second task. **Expected behavior** Keep the draft, its owner and its target. Retry only operations with a server contract that prevents duplicate writes. Verify uncertain results. Show conflicts and permanent failures, with an explicit resend or cancel action. **Steps to reproduce** 1. Save a task title during an outage, then open another task before recovery. 2. Restore a task creation that needs files, selecting those files in another order. 3. Lose a document or decision response, then interrupt the verification read. 4. Switch accounts while a create, upload or connected-channel publication is pending. 5. Type and erase a new-task draft before the first account lookup completes, then reopen it. This PR carries the write layer from #15769 and its review fixes. It targets master after #15754 merged. Related test cleanup #16952 already covers the unchanged copy-link toast test; this PR does not duplicate that work. ## What Changed - Add bounded autosave and submit jobs for supported writes, with pending, conflict and manual retry states. Retain admitted autosaves after navigation and preserve newer task revisions. - Keep each job with its original account, task and operation. First account resolution adopts admitted startup work; a later account change cancels old work. Remount inline and document editors when their owner or document subject changes. - Preserve task-creation keys and immutable request snapshots through file reattachment. Restore generated document keys before comparing reselected files. Reject changed requests instead of creating a second task. - Check the original account scope before creates and each upload. Save successful upload receipts before stopping on an account change. Identify attachments by content digest and metadata. - Verify uncertain document and decision results. Replay only plain, keyed comments automatically; combined reassignment, reopen and run-setting changes require receipt reconciliation or cancel. Settle each request once when a receipt races its retry. - Scope drafts and connected-channel publication reads to their owner. Keep unowned older records unread and unreplayed, with an explicit confirmed discard action after Activity inspection. Preserve typing through the first session lookup, keep erased text erased, and report browser persistence only when storage succeeds. - Wait for the first account lookup before sending startup status changes or decisions. Retry failed required lookups with a fixed limit and surface exhaustion to the original action. Preserve uncertain comment keys on account cancellation. Check the account generation before a comment retry calls its sender and again immediately before POST after asynchronous preparation. Retire only that request’s optimistic echo on completion. - Return an ordinary comment’s accepted same-company, same-task, same-user receipt before workspace reopening, task status changes or wakeups. Reused keys with changed text still conflict. Complete reference and activity records through the same finalizer for initial sends and accepted retries. It locks the task before its comment, matching task and company deletion, comment removal and tombstoning, rejects removed or tombstoned comments, repairs references and inserts a missing activity record in one transaction, and publishes after commit. - Remove reader-only record and comment-body size caps so accepted browser writes remain readable, including JSON-escaped comment requests in local and session storage. Update four existing Storybook fixtures to use owned draft keys. - Document the write contracts and add regression and real browser recovery coverage. - Add the exact reviewed-save recovery regression for both composer implementations and reloads. Align receipt completion, both comment deletion methods and company deletion with task deletion by locking tasks first. Cover real task and company deletion races. ## Verification - The exact reviewed-unknown-save regression fails in all four cases on original commit `1ed0af4186`. It passes with this PR for both composers, before and after reload. Cancel keeps the text and uploaded receipt. The retained draft sends, and the next message also sends. Run `pnpm exec vitest run --project @paperclipai/ui ui/src/components/composer-recovery.test.tsx`. - The new real PostgreSQL task-deletion race fails with SQLSTATE `40P01` on parent commit `f3253a00c4`. The company-deletion race also fails with `40P01` on `e8cf0e02dd`. All six receipt tests pass after the final lock-order fixes. They cover company deletion, task deletion, comment removal, tombstoning, concurrent cross-task references and interrupted reference repair. Run `pnpm exec vitest run --project @paperclipai/server server/src/__tests__/issue-comment-receipt-postgres.test.ts`. - The full UI run passed 9,656 of 9,659 tests across 816 files. Three cases failed under that run: two large-tree timeouts and one plugin-panel assertion. All 67 tests in those two files passed in an isolated rerun. These failures remain recorded; this does not claim a clean full UI run. - `pnpm -r typecheck`, `pnpm build`, token gates and map checks pass on the final change. The local full repository test command was started, then stopped when the delivery moved to this existing PR. Fresh PR CI must provide the full-suite gate. - A local Storybook walkthrough used the real rich editor. Cancel kept the restored text, Send cleared it after the simulated save, and a new draft enabled Send again. Storybook simulates submission; it is not staging acceptance. - Earlier verification on `f3253a00c4` recorded five browser journeys for title saves, cached-task offline recovery, startup recovery, accepted comment replay and session lookup failure. Those results remain prior-head evidence. No new staging deployment is claimed. - On `e8cf0e02dd`, all CI checks passed after one browser-shard rerun. Its first failure copied page text instead of YAML in an unrelated agent-configuration test. On final commit `35131d4380`, Greptile is 5/5 with no unresolved review threads. The first CI attempt has two failures outside this diff: runner close-attachment suspension and scheduled-retry teardown. The scheduled-retry file passes all 14 tests in isolation; the isolated runner test still fails locally. The single standard CI rerun passed both jobs, and the aggregate verification gate passed. All CI checks are green on `35131d4380`; Apex is 5/5 on that same commit with no unresolved findings. ## Risks - Automatic replay requires server duplicate prevention or result verification. Uncertain combined mutations require receipt observation or cancel and a fresh intentional send. - Old unowned form drafts remain stored but are not assigned to an account. Unowned channel records can be retired after Activity inspection; this never cancels an accepted remote update. - Browser storage and hashing can be unavailable. Storage failure is reported honestly. Without a digest, replay can duplicate an upload rather than silently drop a selected file. - Detached autosaves remain bounded and check account ownership on each attempt. Conflicts or exhaustion retain the draft for explicit recovery. - No schema, credential or agent permission change is included. This PR does not claim staging deployment or original-task acceptance. ## Model Used Anthropic Claude Opus 5.5 (`claude-opus-5-5`) produced the original implementation with tool use. OpenAI Codex, GPT-6 family, assisted with restoration, integration, verification and review fixes. This Codex session does not expose its exact serving model ID or context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [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: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Amy (Opus) <claude@users.noreply.github.com> Co-authored-by: Dotta <bippadotta@protonmail.com>
This commit is contained in:
62 files changed
+6486
-733
No files matched your search
@@ -582,6 +582,50 @@ appears, followed briefly by **Back online**. Recovery does not reload the
|
||||
browser. Authorization failures still require sign-in or an explicit retry.
|
||||
Storybook **App / Connection recovery** shows the startup screens and banner.
|
||||
|
||||
Writes keep the user's text through an outage:
|
||||
|
||||
- Comments, new tasks, and board sends store the request and its key
|
||||
(`clientRequestId` or `idempotencyKey`) before they send
|
||||
(`ui/src/lib/pending-send.ts`, `ui/src/hooks/useDurableSubmit.ts`). After a
|
||||
transient failure or a lost response, the composer shows **Sending when
|
||||
reconnected…** with **Resend now** and **Cancel**, and resends with the same
|
||||
key on recovery. The server dedupes by that key. Restored board publications
|
||||
wait for an explicit retry. Restored plain comments and keyed requests resend
|
||||
after their initial backoff. A combined comment that also reassigns, reopens,
|
||||
or changes run settings waits for a matching server receipt or Cancel: comment
|
||||
deduplication alone cannot safely replay those task changes. Cancel returns
|
||||
the text for a fresh, intentional send. A new task restored without its staged
|
||||
files is not sent; the dialog asks for the files again. Restored subtasks keep
|
||||
their saved parent and goal defaults even when reopened from the normal New
|
||||
Task action. Successful staged-file
|
||||
uploads are identified by content digest as well as metadata, so distinct files
|
||||
with identical names, sizes and timestamps are both uploaded. Without browser
|
||||
hashing support, uploads proceed without receipt-based deduplication.
|
||||
- Document and inline-editor autosave (`ui/src/hooks/useDurableAutosave.ts`)
|
||||
keeps unsaved edits in browser storage, shows **Will save when reconnected**,
|
||||
and saves the newest draft for each task or document on recovery. A `409`
|
||||
whose server copy equals the draft counts as saved. A temporary failure of
|
||||
that verification stays pending. Exhausted attempts show a manual retry action.
|
||||
Admitted edits can finish after route navigation unmounts the editor. That
|
||||
work waits for recovery events while offline, uses bounded backoff while
|
||||
online, and verifies the current account before each retry. The latest admitted
|
||||
save is shared by account and target across editor mounts, so a superseded
|
||||
detached retry cannot overwrite a newer edit after reopening the same task.
|
||||
Replayable task-field updates and interaction resolutions bind the original
|
||||
account, account generation and operation at dispatch. A sign-out or account
|
||||
change cancels that intent before it can send, including after waiting behind
|
||||
another write; callbacks cannot apply an old result to the next account.
|
||||
Stored document drafts and mounted editors belong to the signed-in user.
|
||||
Storage failure shows a warning to keep the tab open or copy the text.
|
||||
- Issue field updates go through `useUpdateIssueMutation`: optimistic, rolled
|
||||
back with readable copy on failure, and replayed on reconnect for absolute
|
||||
fields. Updates run in order, so an older update that retries cannot
|
||||
overwrite a newer one.
|
||||
- Uploads and other non-idempotent requests never resend on their own; they
|
||||
keep the input and offer **Retry**.
|
||||
|
||||
The banner counts every write that waits for the connection.
|
||||
|
||||
To reproduce a deploy blip in a dev or QA build, set the outage simulator in the
|
||||
browser console and remove it to end the outage:
|
||||
|
||||
@@ -3399,6 +3443,64 @@ which reinstalls dependencies when `node_modules` is missing. Repositories witho
|
||||
a provision command rely on the agent's own install step, as they do for a new
|
||||
worktree.
|
||||
|
||||
### Browser write recovery
|
||||
|
||||
Use `useDurableAutosave` for replayable field updates and `useDurableSubmit` for
|
||||
user submissions that have an idempotent server contract. Bind queued jobs to
|
||||
the original task and save callback. A task switch must not retarget pending work.
|
||||
Keep the original creation key when a restored draft needs its attachments again;
|
||||
attachment selection order does not create a new request. Document and decision
|
||||
writes with an uncertain response verify the saved result before reporting failure.
|
||||
Verification reads can themselves need retry. Conflicts and permanent failures
|
||||
must reach the caller. Once automatic attempts stop, show **Resend now** instead
|
||||
of promising another automatic attempt. See the hook, document, task creation and
|
||||
connected-task banner tests for these contracts.
|
||||
|
||||
Inline editors preserve typing through the first owner lookup and remount after
|
||||
a known draft owner changes. Connected-channel sends use
|
||||
account-scoped storage and publication queries; retries and receipts retain the
|
||||
original account generation. Returning to that account restores its saved send
|
||||
for an explicit same-key retry. Earlier unowned channel receipts are preserved
|
||||
without revealing or replaying their text. They require inspecting Activity
|
||||
before a new send because their original sender cannot be established safely.
|
||||
After checking Activity, a person can explicitly discard the unowned browser
|
||||
record to start a fresh update. This never replays the old text or cancels an
|
||||
already accepted publication; storage failures keep the hold in place.
|
||||
|
||||
Task-creation drafts and recovery state are account-scoped. A known account
|
||||
change remounts the dialog, clears staged in-memory files and stops old creates
|
||||
before any subsequent upload. An interrupted account-bound request stays pending
|
||||
under its original key; it is never relabelled as a provider refusal. Legacy
|
||||
unowned form drafts remain in browser storage but are not restored into another
|
||||
account. Storybook fixtures seed the same account-specific keys.
|
||||
|
||||
Successful pending-record writes have the same readable size range as browser
|
||||
storage. In particular, a large task description cannot silently drop its
|
||||
creation key on reload. Reselected text documents recover their original
|
||||
file-to-document-key mapping before fingerprint comparison and upload.
|
||||
Document editors remount on subject changes, and a message attempt settles only
|
||||
once when a server receipt races its HTTP retry. Startup autosaves bind to the
|
||||
first resolved account; a later known-account change still cancels old work.
|
||||
Account-bound status changes and decisions also wait for the first lookup rather
|
||||
than rejecting an action made while it loads. Waiting work starts a bounded retry
|
||||
of a failed session lookup; exhaustion reaches the original error callback instead
|
||||
of leaving the operation pending. Removing that session scope cancels its lookup
|
||||
work. Account cancellation after an uncertain POST keeps the original request key.
|
||||
Comment retries check the original
|
||||
account generation at the click and again immediately before their POST, including
|
||||
after optimistic cache cancellation or conversation creation. A retained accepted
|
||||
comment returns its existing receipt before the route can reopen a completed task.
|
||||
`server/src/services/issue-comment-receipt.ts` completes reference and activity
|
||||
records for keyed board comments. It locks the same-company task before its
|
||||
comment, matching task and company deletion, comment removal and tombstoning. It repairs references and
|
||||
inserts a missing activity record in one
|
||||
transaction, and publishes after commit. The task uses `FOR NO KEY UPDATE` so
|
||||
concurrent comments can reference each other without blocking foreign-key checks. Both the initial send and a retained
|
||||
receipt use this owner so concurrent completion cannot duplicate the activity.
|
||||
Large comment request records use the same readable range as successful writes,
|
||||
including JSON-escaped bodies. Account changes retire only the original optimistic
|
||||
echo and cannot apply its rollback to the next account.
|
||||
|
||||
### Browser realtime connection recovery
|
||||
|
||||
If a browser cannot construct a WebSocket, the live-update and run transcript
|
||||
|
||||
@@ -514,6 +514,12 @@ Gaps: Legacy/native/channel runtimes separately qualify; preserve original undis
|
||||
|
||||
Capability ID: `recovery/reconnect`.
|
||||
|
||||
Browser autosave and resend preserve the existing actor, task and idempotency
|
||||
contracts. They grant no new agent access. Retrying a document or decision still
|
||||
requires its normal authorized operation; an uncertain outcome requires saved-result
|
||||
verification rather than an unguarded repeat.
|
||||
|
||||
|
||||
Review: `operation-reviewed`.
|
||||
|
||||
Browser transport recovery refetches persisted state without implying runner recovery.
|
||||
|
||||
@@ -274,7 +274,7 @@ API: `present` — POST /api/issues/{id}/comments; PATCH task with comment.
|
||||
|
||||
Agent access: `partial` — REST/call_api, CLI issue comment/update--comment; native report_progress plain CURRENT-task body+stable key only. answer_status_question is declared but not production-admitted.
|
||||
|
||||
Policy: issue:comment/responsible-user/influence ceilings may allow peer/direct-parent reporting without disposition authority. body nonempty, clientRequestId UUID, maximum 20 unique attachment IDs. Agent onBehalfOfUserId rejected; presentation/metadata board-only; authorType cannot gain board wake. Board paused task returns 409. Board Agent Chat additionally flag/owner/clientRequestId. Pending/running retries preserved by ordinary board comments; explicit control separate.
|
||||
Policy: issue:comment/responsible-user/influence ceilings may allow peer/direct-parent reporting without disposition authority. body nonempty, clientRequestId UUID, maximum 20 unique attachment IDs. Agent onBehalfOfUserId rejected; presentation/metadata board-only; authorType cannot gain board wake. A new board comment on a paused task returns 409. An ordinary board comment whose same-task, same-user request key was already accepted repairs its reference/activity records and returns that receipt before task mutations; different text conflicts without reopening work. Completion serializes on the same-company task/comment and does not duplicate the activity. Board Agent Chat additionally flag/owner/clientRequestId. Pending/running retries preserved by ordinary board comments; explicit control separate.
|
||||
|
||||
Evidence: `source-only` — reviewed at `3bbc2104b65caf9f7763938a01260a5673e12f7a`; [issues.ts](../../server/src/routes/issues.ts), [issue.ts](../../packages/shared/src/validators/issue.ts), [paperclip-runner-tool-authority.ts](../../server/src/services/native-runtime/paperclip-runner-tool-authority.ts), [issue.ts](../../cli/src/commands/client/issue.ts), [issue-comment-attribution-audit-routes.test.ts](../../server/src/__tests__/issue-comment-attribution-audit-routes.test.ts), [issue-comment-reopen-routes.test.ts](../../server/src/__tests__/issue-comment-reopen-routes.test.ts), [cross-issue-influence-limit-postgres.test.ts](../../server/src/__tests__/cross-issue-influence-limit-postgres.test.ts). No live execution claimed.
|
||||
|
||||
|
||||
@@ -58,6 +58,44 @@ Agent access: [companion](../agent-access-map/recovery.md).
|
||||
|
||||
## How to get to it (user POV)
|
||||
|
||||
For `recovery/reconnect`, also interrupt a write response after the server accepts
|
||||
it. Keep a title draft bound to its original task while navigating. Restore a
|
||||
new-task draft with its files selected in another order and require the same
|
||||
creation key and one task. Verify lost document-create and decision responses,
|
||||
including an interrupted verification read. Once the bounded retry budget ends,
|
||||
require a visible **Resend now** action. Use
|
||||
[durable autosave tests](../../ui/src/hooks/useDurableAutosave.test.tsx) and
|
||||
[task creation tests](../../ui/src/components/NewIssueDialog.test.tsx) for the
|
||||
controlled source cases; repeat the real browser journey for acceptance.
|
||||
Switch accounts while an inline edit is open or a channel send waits to retry:
|
||||
the next account must see neither private text nor a retry from the first account.
|
||||
Returning to the original account restores its retained channel request with the
|
||||
same key for explicit retry. Unowned legacy receipts stay held for Activity
|
||||
inspection until a person explicitly discards their browser record; discard does
|
||||
not replay the text or cancel an accepted update. Typing before the first session
|
||||
lookup survives that initial lookup and is backed up under the resolved owner. Component evidence lives in the
|
||||
[inline editor tests](../../ui/src/components/InlineEditor.test.tsx) and
|
||||
[connected-channel tests](../../ui/src/components/chat/ExternallyConnectedTaskBanner.test.tsx);
|
||||
these controlled session changes do not establish hosted authentication acceptance.
|
||||
Also switch accounts during a restored task create and while its files are
|
||||
uploading: later uploads stop, the next account sees a fresh form, and the
|
||||
original request key remains owned by the first account. Reselect colliding
|
||||
text-document filenames in reverse order and verify their original keys and
|
||||
contents. Switch between cached document subjects without copying either draft.
|
||||
A receipt arriving before an in-flight resend must preserve the next message.
|
||||
The startup browser journey in
|
||||
[disconnect recovery](../../tests/e2e/disconnect-write-recovery.spec.ts) holds the
|
||||
first session response while a title save waits, simulates an offline event with
|
||||
API failures while keeping static assets reachable, then verifies the real saved
|
||||
server title after account resolution and reload. The accepted-comment journey
|
||||
then completes and assigns a task before repeating its saved comment request;
|
||||
the original receipt must return once, its activity must appear once, and the task must stay Done. Reusing that
|
||||
key with different text must return a conflict without changing the task. This
|
||||
uses a disposable process agent and makes no provider call. A separate healthy-API
|
||||
startup journey fails the session endpoint with JSON 500 responses, then restores
|
||||
it while an edit waits; the same edit must save and survive reload without a
|
||||
network reconnect or another user action.
|
||||
|
||||
### `source-task`
|
||||
|
||||
Open the affected task from Tasks, Inbox/Blocked, search, or a direct task link.
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { activityLog, companies, createDb, issueComments, issueReferenceMentions, issues } from "@paperclipai/db";
|
||||
import { completeIssueCommentReceipt } from "../services/issue-comment-receipt.js";
|
||||
import { issueReferenceService } from "../services/issue-references.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { deleteCompany } from "../services/company-deletion.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePostgres = support.supported ? describe : describe.skip;
|
||||
|
||||
describePostgres("accepted comment finalization", () => {
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-comment-receipt-");
|
||||
db = createDb(database.connectionString);
|
||||
}, 30_000);
|
||||
afterAll(async () => {
|
||||
await db?.$client.end({ timeout: 5 });
|
||||
await database?.cleanup();
|
||||
});
|
||||
|
||||
it("rolls back partial references and repairs them once across concurrent retries", async () => {
|
||||
const companyId = randomUUID(), issueId = randomUUID(), targetId = randomUUID(), commentId = randomUUID();
|
||||
const prefix = `RCP${companyId.slice(0, 6).toUpperCase()}`;
|
||||
await db.insert(companies).values({ id: companyId, name: "Comment receipt recovery", issuePrefix: prefix });
|
||||
await db.insert(issues).values([
|
||||
{ id: issueId, companyId, title: "Completed task", status: "done", identifier: `${prefix}-1`, issueNumber: 1 },
|
||||
{ id: targetId, companyId, title: "Referenced task", identifier: `${prefix}-2`, issueNumber: 2 },
|
||||
]);
|
||||
await db.insert(issueComments).values({
|
||||
id: commentId, companyId, issueId, authorType: "user", authorUserId: "local-board",
|
||||
clientRequestId: randomUUID(), body: `See ${prefix}-2`,
|
||||
});
|
||||
const refs = issueReferenceService(db);
|
||||
let fail = true;
|
||||
const interrupted = {
|
||||
...refs,
|
||||
syncComment: async (...args: Parameters<typeof refs.syncComment>) => {
|
||||
await refs.syncComment(...args);
|
||||
if (fail) throw new Error("Reference write interrupted before activity");
|
||||
},
|
||||
};
|
||||
const input = {
|
||||
companyId, issueId, commentId,
|
||||
activity: () => ({ companyId, actorType: "user" as const, actorId: "local-board", action: "issue.comment_added",
|
||||
entityType: "issue", entityId: issueId, details: { commentId } }),
|
||||
};
|
||||
await expect(completeIssueCommentReceipt(db, interrupted, input)).rejects.toThrow("Reference write interrupted");
|
||||
expect(await db.select().from(issueReferenceMentions).where(eq(issueReferenceMentions.sourceRecordId, commentId))).toHaveLength(0);
|
||||
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).toHaveLength(0);
|
||||
fail = false;
|
||||
await Promise.all(Array.from({ length: 3 }, () => completeIssueCommentReceipt(db, interrupted, input)));
|
||||
expect(await db.select().from(issueReferenceMentions).where(and(
|
||||
eq(issueReferenceMentions.sourceRecordId, commentId), eq(issueReferenceMentions.targetIssueId, targetId),
|
||||
))).toHaveLength(1);
|
||||
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).toHaveLength(1);
|
||||
await db.delete(issueReferenceMentions).where(eq(issueReferenceMentions.sourceRecordId, commentId));
|
||||
await completeIssueCommentReceipt(db, refs, input);
|
||||
expect(await db.select().from(issueReferenceMentions).where(eq(issueReferenceMentions.sourceRecordId, commentId))).toHaveLength(1);
|
||||
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).toHaveLength(1);
|
||||
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].status).toBe("done");
|
||||
await expect(completeIssueCommentReceipt(db, refs, { ...input, companyId: randomUUID() })).rejects.toMatchObject({ status: 404 });
|
||||
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).toHaveLength(1);
|
||||
}, 30_000);
|
||||
it("completes concurrent cross-task references without a deadlock", async () => {
|
||||
const companyId = randomUUID(), firstId = randomUUID(), secondId = randomUUID();
|
||||
const firstCommentId = randomUUID(), secondCommentId = randomUUID();
|
||||
const prefix = `RCP${companyId.slice(0, 6).toUpperCase()}`;
|
||||
await db.insert(companies).values({ id: companyId, name: "Cross-task comment recovery", issuePrefix: prefix });
|
||||
await db.insert(issues).values([
|
||||
{ id: firstId, companyId, title: "First task", status: "done", identifier: `${prefix}-1`, issueNumber: 1 },
|
||||
{ id: secondId, companyId, title: "Second task", status: "done", identifier: `${prefix}-2`, issueNumber: 2 },
|
||||
]);
|
||||
await db.insert(issueComments).values([
|
||||
{ id: firstCommentId, companyId, issueId: firstId, authorUserId: "local-board", body: `See ${prefix}-2` },
|
||||
{ id: secondCommentId, companyId, issueId: secondId, authorUserId: "local-board", body: `See ${prefix}-1` },
|
||||
]);
|
||||
const refs = issueReferenceService(db);
|
||||
let arrived = 0;
|
||||
let release!: () => void;
|
||||
const bothLocked = new Promise<void>(resolve => { release = resolve; });
|
||||
const synchronized = {
|
||||
...refs,
|
||||
syncComment: async (...args: Parameters<typeof refs.syncComment>) => {
|
||||
if (++arrived === 2) release();
|
||||
await bothLocked;
|
||||
await refs.syncComment(...args);
|
||||
},
|
||||
};
|
||||
const results = await Promise.allSettled([[firstId, firstCommentId], [secondId, secondCommentId]].map(([issueId, commentId]) =>
|
||||
completeIssueCommentReceipt(db, synchronized, { companyId, issueId, commentId,
|
||||
activity: () => ({ companyId, actorType: "user", actorId: "local-board", action: "issue.comment_added",
|
||||
entityType: "issue", entityId: issueId, details: { commentId } }),
|
||||
})));
|
||||
expect(results.map(result => result.status)).toEqual(["fulfilled", "fulfilled"]);
|
||||
expect(await db.select().from(issueReferenceMentions).where(eq(issueReferenceMentions.companyId, companyId))).toHaveLength(2);
|
||||
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).toHaveLength(2);
|
||||
expect((await db.select().from(issues).where(eq(issues.companyId, companyId))).every(issue => issue.status === "done")).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
it.each(["remove", "tombstone"] as const)("serializes completion with a real %s without a deadlock", async (kind) => {
|
||||
const companyId = randomUUID(), issueId = randomUUID(), commentId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Comment deletion race", issuePrefix: `RCP${companyId.slice(0, 6).toUpperCase()}` });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Completed task", status: "done" });
|
||||
await db.insert(issueComments).values({ id: commentId, companyId, issueId, authorUserId: "local-board", body: "Saved comment" });
|
||||
await issueService(db).removeComment(randomUUID());
|
||||
const monitored = (started: (pid: number) => void) => new Proxy(db, {
|
||||
get(target, property) {
|
||||
if (property === "transaction") return (callback: (tx: any) => Promise<unknown>) => target.transaction(async tx => {
|
||||
const [row] = await tx.execute(sql`select pg_backend_pid() as pid`);
|
||||
started(Number(row.pid));
|
||||
return callback(tx);
|
||||
});
|
||||
const value = Reflect.get(target, property, target);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
const waitForLock = async (pid: number) => vi.waitFor(async () => {
|
||||
const [state] = await db.execute(sql`select wait_event_type from pg_stat_activity where pid=${pid}`);
|
||||
expect(state?.wait_event_type).toBe("Lock");
|
||||
}, { timeout: 5_000, interval: 10 });
|
||||
let release!: () => void, held!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
const ready = new Promise<void>(resolve => { held = resolve; });
|
||||
const holding = db.transaction(async tx => {
|
||||
await tx.select().from(issueComments).where(eq(issueComments.id, commentId)).for("update");
|
||||
held();
|
||||
await gate;
|
||||
});
|
||||
const operations: Promise<unknown>[] = [holding];
|
||||
try {
|
||||
await ready;
|
||||
let deleteStarted!: (pid: number) => void;
|
||||
const deletePid = new Promise<number>(resolve => { deleteStarted = resolve; });
|
||||
const deletingService = issueService(monitored(deleteStarted));
|
||||
const deleting = (kind === "remove" ? deletingService.removeComment(commentId)
|
||||
: deletingService.tombstoneComment(commentId, { actorType: "user", userId: "local-board" })).catch(error => error);
|
||||
operations.push(deleting);
|
||||
await waitForLock(await deletePid);
|
||||
let completeStarted!: (pid: number) => void;
|
||||
const completePid = new Promise<number>(resolve => { completeStarted = resolve; });
|
||||
const completing = completeIssueCommentReceipt(monitored(completeStarted), issueReferenceService(db), {
|
||||
companyId, issueId, commentId,
|
||||
activity: () => ({ companyId, actorType: "user", actorId: "local-board", action: "issue.comment_added",
|
||||
entityType: "issue", entityId: issueId, details: { commentId } }),
|
||||
}).catch(error => error);
|
||||
operations.push(completing);
|
||||
await waitForLock(await completePid);
|
||||
release();
|
||||
await holding;
|
||||
expect(await deleting).toMatchObject({ id: commentId });
|
||||
expect(await completing).toMatchObject({ status: 404 });
|
||||
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).toHaveLength(0);
|
||||
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].status).toBe("done");
|
||||
} finally {
|
||||
release();
|
||||
await Promise.allSettled(operations);
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it.each(["task", "company"] as const)("serializes receipt completion with actual %s deletion", async kind => {
|
||||
const companyId = randomUUID(), issueId = randomUUID(), commentId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Task deletion race", issuePrefix: `RCP${companyId.slice(0, 6).toUpperCase()}` });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Completed task", status: "done" });
|
||||
await db.insert(issueComments).values({ id: commentId, companyId, issueId, authorUserId: "local-board", body: "Saved comment" });
|
||||
let release!: () => void, held!: () => void, receiptStarted!: (pid: number) => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
const ready = new Promise<void>(resolve => { held = resolve; });
|
||||
const receiptPid = new Promise<number>(resolve => { receiptStarted = resolve; });
|
||||
const wrap = (deleting: boolean) => new Proxy(db, {
|
||||
get(target, property) {
|
||||
if (property === "transaction") return (callback: (tx: any) => Promise<unknown>) => target.transaction(async tx => {
|
||||
if (!deleting) {
|
||||
const [row] = await tx.execute(sql`select pg_backend_pid() as pid`);
|
||||
receiptStarted(Number(row.pid));
|
||||
}
|
||||
return callback(new Proxy(tx, {
|
||||
get(transaction, method) {
|
||||
// Delay the real task DELETE, preserving each service's normal
|
||||
// earlier locks (including company deletion's child-row locks).
|
||||
if (deleting && method === "delete") return (table: unknown) => {
|
||||
const builder = transaction.delete(table as typeof issues);
|
||||
if (table !== issues) return builder;
|
||||
return { where: (...args: Parameters<typeof builder.where>) => {
|
||||
const query = builder.where(...args);
|
||||
const wait = async () => { held(); await gate; };
|
||||
return kind === "task"
|
||||
? { returning: async () => { await wait(); return query.returning(); } }
|
||||
: (async () => { await wait(); return query; })();
|
||||
} };
|
||||
};
|
||||
const value = Reflect.get(transaction, method, transaction);
|
||||
return typeof value === "function" ? value.bind(transaction) : value;
|
||||
},
|
||||
}));
|
||||
});
|
||||
const value = Reflect.get(target, property, target);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
const deleting = (kind === "task" ? issueService(wrap(true)).remove(issueId)
|
||||
: deleteCompany(wrap(true), companyId)).catch(error => error);
|
||||
let completing: Promise<unknown> | undefined;
|
||||
try {
|
||||
await ready;
|
||||
completing = completeIssueCommentReceipt(wrap(false), issueReferenceService(db), {
|
||||
companyId, issueId, commentId,
|
||||
activity: () => ({ companyId, actorType: "user", actorId: "local-board", action: "issue.comment_added",
|
||||
entityType: "issue", entityId: issueId, details: { commentId } }),
|
||||
}).catch(error => error);
|
||||
const pid = await receiptPid;
|
||||
await vi.waitFor(async () => {
|
||||
const [state] = await db.execute(sql`select wait_event_type from pg_stat_activity where pid=${pid}`);
|
||||
expect(state?.wait_event_type).toBe("Lock");
|
||||
}, { timeout: 5_000, interval: 10 });
|
||||
release();
|
||||
expect(await deleting).toMatchObject({ id: kind === "task" ? issueId : companyId });
|
||||
expect(await completing).toMatchObject({ status: 404 });
|
||||
expect(await db.select().from(issues).where(eq(issues.id, issueId))).toHaveLength(0);
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.id, commentId))).toHaveLength(0);
|
||||
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).toHaveLength(0);
|
||||
if (kind === "company") expect(await db.select().from(companies).where(eq(companies.id, companyId))).toHaveLength(0);
|
||||
} finally {
|
||||
release();
|
||||
await Promise.allSettled([deleting, completing]);
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -49,17 +49,20 @@ const mockAgentService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const mockSyncCommentReferences = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const mockTxInsertValues = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const mockTxInsert = vi.hoisted(() =>
|
||||
vi.fn(() => ({ values: mockTxInsertValues })),
|
||||
);
|
||||
const mockTx = vi.hoisted(() => ({
|
||||
select: () => mockDbSelect(),
|
||||
insert: mockTxInsert,
|
||||
}));
|
||||
const mockDbSelectOrderBy = vi.hoisted(() => vi.fn(async () => []));
|
||||
const mockDbSelectWhere = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
orderBy: mockDbSelectOrderBy,
|
||||
for: async () => [{ id: "comment-1" }],
|
||||
then: (
|
||||
onFulfilled: (rows: unknown[]) => unknown,
|
||||
onRejected?: (reason: unknown) => unknown,
|
||||
@@ -203,7 +206,7 @@ vi.mock("../services/index.js", () => ({
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncComment: mockSyncCommentReferences,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
@@ -397,6 +400,7 @@ describe("issue comment reopen routes", () => {
|
||||
mockAgentService.list.mockReset();
|
||||
mockAgentService.resolveByReference.mockReset();
|
||||
mockLogActivity.mockReset();
|
||||
mockSyncCommentReferences.mockReset().mockResolvedValue(undefined);
|
||||
mockFeedbackService.listIssueVotesForUser.mockReset();
|
||||
mockFeedbackService.saveIssueVote.mockReset();
|
||||
mockInstanceSettingsService.get.mockReset();
|
||||
@@ -422,6 +426,7 @@ describe("issue comment reopen routes", () => {
|
||||
mockDbSelectOrderBy.mockResolvedValue([]);
|
||||
mockDbSelectWhere.mockImplementation(() => ({
|
||||
orderBy: mockDbSelectOrderBy,
|
||||
for: async () => [{ id: "comment-1" }],
|
||||
then: (
|
||||
onFulfilled: (rows: unknown[]) => unknown,
|
||||
onRejected?: (reason: unknown) => unknown,
|
||||
@@ -573,6 +578,41 @@ describe("issue comment reopen routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("finishes missing reference and activity writes for an accepted comment", async () => {
|
||||
const issue = makeIssue("done");
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
const selectFrom = mockDbSelectFrom.getMockImplementation()!;
|
||||
mockDbSelectFrom.mockImplementation((table) => getTableName(table as Parameters<typeof getTableName>[0]) === "issue_comments"
|
||||
? { where: vi.fn(() => Object.assign(Promise.resolve([{ id: "comment-1" }]), { for: async () => [{ id: "comment-1" }] })) } as any : selectFrom(table));
|
||||
const response = await request(await installActor(createApp())).post(`/api/issues/${issue.id}/comments`)
|
||||
.send({ body: "hello", clientRequestId: "77777777-7777-4777-8777-777777777777" });
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockSyncCommentReferences).toHaveBeenCalledWith("comment-1", expect.anything());
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "issue.comment_added", details: expect.objectContaining({ commentId: "comment-1" }),
|
||||
}), expect.anything());
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])("returns a retained comment before reopening completed work (changed body=%s)", async (changed) => {
|
||||
const issue = makeIssue("done");
|
||||
const requestId = "77777777-7777-4777-8777-777777777777";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
const selectFrom = mockDbSelectFrom.getMockImplementation()!;
|
||||
mockDbSelectFrom.mockImplementation((table) => getTableName(table as Parameters<typeof getTableName>[0]) === "issue_comments"
|
||||
? { where: vi.fn(() => Object.assign(Promise.resolve([{ id: "comment-1" }]), { for: async () => [{ id: "comment-1" }] })) } as any : getTableName(table as Parameters<typeof getTableName>[0]) === "activity_log"
|
||||
? { where: async () => [{ id: "activity-1" }] } as any : selectFrom(table));
|
||||
if (changed) mockIssueService.addComment.mockRejectedValue(new HttpError(409, "Message request ID was already used for different content"));
|
||||
const response = await request(await installActor(createApp())).post(`/api/issues/${issue.id}/comments`)
|
||||
.send({ body: changed ? "Changed body" : "hello", clientRequestId: requestId });
|
||||
expect(response.status).toBe(changed ? 409 : 201);
|
||||
if (!changed) expect(response.body.id).toBe("comment-1");
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats reopen=true as a no-op when the issue is already open", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
|
||||
mockIssueService.update.mockImplementation(
|
||||
|
||||
@@ -2,6 +2,11 @@ import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../services/activity-log.js", () => ({
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
publishActivity: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/fast-responses.js", () => ({
|
||||
enqueueFastResponse: vi.fn(async () => undefined),
|
||||
}));
|
||||
@@ -222,7 +227,7 @@ async function loadAppModules() {
|
||||
}
|
||||
|
||||
async function createApp(transaction: (callback: (tx: Record<string, never>) => Promise<unknown>) => Promise<unknown> =
|
||||
async (callback) => callback({})) {
|
||||
async (callback) => callback({ select: () => ({ from: () => ({ where: () => Object.assign(Promise.resolve([]), { for: async () => [{ id: "comment-3" }] }) }) }) } as any)) {
|
||||
const [{ errorHandler }, { issueRoutes }] = await loadAppModules();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -239,6 +244,7 @@ async function createApp(transaction: (callback: (tx: Record<string, never>) =>
|
||||
});
|
||||
app.use("/api", issueRoutes({
|
||||
transaction,
|
||||
select: () => ({ from: () => ({ where: async () => [] }) }),
|
||||
} as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
|
||||
+111
-67
@@ -1,3 +1,4 @@
|
||||
import { completeIssueCommentReceipt } from "../services/issue-comment-receipt.js";
|
||||
import { taskInterruptionPresentation, taskInterruptionPresentations } from "../services/task-interruption-presentation.js";
|
||||
import { parseObject } from "../adapters/utils.js";
|
||||
import { prepareRestrictedWorkspaceRetry } from "../services/restricted-workspace-retry.js";
|
||||
@@ -18468,7 +18469,6 @@ export function issueRoutes(
|
||||
issue,
|
||||
);
|
||||
if (!commentAccessDecision) return;
|
||||
if (!(await assertBoardCommentNotPaused(req, res, issue))) return;
|
||||
const commentAuthorizationReason = issueWriteAuthorizationReason(
|
||||
req,
|
||||
commentAccessDecision,
|
||||
@@ -18480,6 +18480,42 @@ export function issueRoutes(
|
||||
})
|
||||
)
|
||||
return;
|
||||
if (req.actor.type === "board" && req.actor.userId && req.body.clientRequestId) {
|
||||
const [accepted] = await db.select({ id: issueComments.id }).from(issueComments).where(and(
|
||||
eq(issueComments.companyId, issue.companyId), eq(issueComments.issueId, issue.id),
|
||||
eq(issueComments.authorUserId, req.actor.userId), eq(issueComments.clientRequestId, req.body.clientRequestId),
|
||||
));
|
||||
if (accepted) {
|
||||
const receipt = await svc.addComment(issue.id, req.body.body, { userId: req.actor.userId }, {
|
||||
clientRequestId: req.body.clientRequestId, attachmentIds: req.body.attachmentIds,
|
||||
authorType: "user",
|
||||
});
|
||||
const userId = req.actor.userId;
|
||||
await completeIssueCommentReceipt(db, issueReferencesSvc, {
|
||||
companyId: issue.companyId, issueId: issue.id, commentId: receipt.id,
|
||||
activity: diff => ({
|
||||
companyId: issue.companyId, actorType: "user", actorId: userId,
|
||||
runId: receipt.createdByRunId,
|
||||
responsibleUserIdOverride: receipt.onBehalfOfUserId ?? userId,
|
||||
action: "issue.comment_added", entityType: "issue", entityId: issue.id,
|
||||
details: {
|
||||
commentId: receipt.id, bodySnippet: receipt.body.slice(0, 120),
|
||||
identifier: issue.identifier, issueTitle: issue.title,
|
||||
authorizationReason: commentAuthorizationReason, source: "comment_receipt_recovery",
|
||||
...summarizeIssueReferenceActivityDetails({
|
||||
addedReferencedIssues: diff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
removedReferencedIssues: diff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
currentReferencedIssues: diff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
await externalObjectsSvc.syncCommentSafely(receipt.id);
|
||||
res.status(201).json(receipt);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!(await assertBoardCommentNotPaused(req, res, issue))) return;
|
||||
const closedExecutionWorkspace =
|
||||
await getClosedIssueExecutionWorkspace(issue);
|
||||
|
||||
@@ -18969,72 +19005,8 @@ export function issueRoutes(
|
||||
.catch(() => logger.warn({ issueId: currentIssue.id }, "fast response admission unavailable"));
|
||||
}
|
||||
|
||||
await issueReferencesSvc.syncComment(comment.id);
|
||||
await externalObjectsSvc.syncCommentSafely(comment.id);
|
||||
if (
|
||||
currentIssue.assigneeAgentId &&
|
||||
// Slack-linked messages need the normal attributed wake boundary so
|
||||
// the accepted requester and return-thread receipt travel with the run.
|
||||
!currentIssue.externalConversationState &&
|
||||
!(
|
||||
actor.actorType === "agent" &&
|
||||
actor.actorId === currentIssue.assigneeAgentId
|
||||
)
|
||||
) {
|
||||
const goalProjection = await runnerGoals.projection(
|
||||
currentIssue.companyId,
|
||||
currentIssue.id,
|
||||
currentIssue.assigneeAgentId,
|
||||
);
|
||||
if (
|
||||
goalProjection?.goal?.status === "active" &&
|
||||
goalProjection.workingNow
|
||||
) {
|
||||
const steer = queueLiveRunnerPrpCommand({
|
||||
companyId: currentIssue.companyId,
|
||||
issueId: currentIssue.id,
|
||||
agentId: currentIssue.assigneeAgentId,
|
||||
type: "turn.steer",
|
||||
payload: { text: comment.body },
|
||||
commandId: `goal_comment_${comment.id}`,
|
||||
});
|
||||
if (steer) {
|
||||
try {
|
||||
await steer.completion;
|
||||
goalCommentSteered = true;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
issueId: currentIssue.id,
|
||||
commentId: comment.id,
|
||||
runId: steer.runId,
|
||||
},
|
||||
"failed to steer an active session goal; falling back to a boundary wake",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const commentReferenceSummaryAfter =
|
||||
await issueReferencesSvc.listIssueReferenceSummary(currentIssue.id);
|
||||
const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
|
||||
commentReferenceSummaryBefore,
|
||||
commentReferenceSummaryAfter,
|
||||
);
|
||||
|
||||
if (actor.runId) {
|
||||
await heartbeat
|
||||
.reportRunActivity(actor.runId)
|
||||
.catch((err) =>
|
||||
logger.warn(
|
||||
{ err, runId: actor.runId },
|
||||
"failed to clear detached run warning after issue comment",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
const keyedComment = req.actor.type === "board" && Boolean(req.actor.userId && req.body.clientRequestId);
|
||||
const buildCommentActivity = (commentReferenceDiff: ReturnType<typeof issueReferencesSvc.diffIssueReferenceSummary>) => ({
|
||||
companyId: currentIssue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
@@ -19091,6 +19063,78 @@ export function issueRoutes(
|
||||
}),
|
||||
},
|
||||
});
|
||||
if (keyedComment) await completeIssueCommentReceipt(db, issueReferencesSvc, {
|
||||
companyId: currentIssue.companyId, issueId: currentIssue.id, commentId: comment.id,
|
||||
referenceSummaryBefore: commentReferenceSummaryBefore, activity: buildCommentActivity,
|
||||
});
|
||||
else await issueReferencesSvc.syncComment(comment.id);
|
||||
await externalObjectsSvc.syncCommentSafely(comment.id);
|
||||
if (
|
||||
currentIssue.assigneeAgentId &&
|
||||
// Slack-linked messages need the normal attributed wake boundary so
|
||||
// the accepted requester and return-thread receipt travel with the run.
|
||||
!currentIssue.externalConversationState &&
|
||||
!(
|
||||
actor.actorType === "agent" &&
|
||||
actor.actorId === currentIssue.assigneeAgentId
|
||||
)
|
||||
) {
|
||||
const goalProjection = await runnerGoals.projection(
|
||||
currentIssue.companyId,
|
||||
currentIssue.id,
|
||||
currentIssue.assigneeAgentId,
|
||||
);
|
||||
if (
|
||||
goalProjection?.goal?.status === "active" &&
|
||||
goalProjection.workingNow
|
||||
) {
|
||||
const steer = queueLiveRunnerPrpCommand({
|
||||
companyId: currentIssue.companyId,
|
||||
issueId: currentIssue.id,
|
||||
agentId: currentIssue.assigneeAgentId,
|
||||
type: "turn.steer",
|
||||
payload: { text: comment.body },
|
||||
commandId: `goal_comment_${comment.id}`,
|
||||
});
|
||||
if (steer) {
|
||||
try {
|
||||
await steer.completion;
|
||||
goalCommentSteered = true;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
issueId: currentIssue.id,
|
||||
commentId: comment.id,
|
||||
runId: steer.runId,
|
||||
},
|
||||
"failed to steer an active session goal; falling back to a boundary wake",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (actor.runId) {
|
||||
await heartbeat
|
||||
.reportRunActivity(actor.runId)
|
||||
.catch((err) =>
|
||||
logger.warn(
|
||||
{ err, runId: actor.runId },
|
||||
"failed to clear detached run warning after issue comment",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!keyedComment) {
|
||||
const commentReferenceSummaryAfter =
|
||||
await issueReferencesSvc.listIssueReferenceSummary(currentIssue.id);
|
||||
const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
|
||||
commentReferenceSummaryBefore,
|
||||
commentReferenceSummaryAfter,
|
||||
);
|
||||
await logActivity(db, buildCommentActivity(commentReferenceDiff));
|
||||
}
|
||||
|
||||
const expiredInteractions = await issueThreadInteractionService(
|
||||
db,
|
||||
|
||||
@@ -100,6 +100,9 @@ export async function deleteCompany(db: Db, id: string) {
|
||||
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id));
|
||||
await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id));
|
||||
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.companyId, id));
|
||||
// Match receipt completion and comment deletion: task before comment.
|
||||
await tx.select({ id: issues.id }).from(issues)
|
||||
.where(eq(issues.companyId, id)).orderBy(issues.id).for("no key update");
|
||||
await tx.delete(issueComments).where(eq(issueComments.companyId, id));
|
||||
await tx.delete(approvalComments).where(eq(approvalComments.companyId, id));
|
||||
await notifyCompanyRetentionWork(tx, id);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { activityLog, issueComments, issues, type Db } from "@paperclipai/db";
|
||||
import type { IssueRelatedWorkSummary } from "@paperclipai/shared";
|
||||
import { notFound } from "../errors.js";
|
||||
import { logActivity, publishActivity, type ActivityPublication, type LogActivityInput } from "./activity-log.js";
|
||||
import type { issueReferenceService } from "./issue-references.js";
|
||||
|
||||
type CommentReferences = Pick<ReturnType<typeof issueReferenceService>,
|
||||
"syncComment" | "listIssueReferenceSummary" | "diffIssueReferenceSummary">;
|
||||
type ReferenceDiff = ReturnType<CommentReferences["diffIssueReferenceSummary"]>;
|
||||
|
||||
export async function completeIssueCommentReceipt(db: Db, references: CommentReferences, input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
commentId: string;
|
||||
referenceSummaryBefore?: IssueRelatedWorkSummary;
|
||||
activity: (diff: ReferenceDiff) => LogActivityInput;
|
||||
}) {
|
||||
const publications: ActivityPublication[] = [];
|
||||
await db.transaction(async tx => {
|
||||
const connection = tx as unknown as Db;
|
||||
// Match task deletion and comment mutation: lock the task before its
|
||||
// comment. NO KEY UPDATE still permits references from other tasks.
|
||||
const [issue] = await tx.select({ id: issues.id }).from(issues).where(and(
|
||||
eq(issues.id, input.issueId), eq(issues.companyId, input.companyId),
|
||||
)).for("no key update");
|
||||
if (!issue) throw notFound("Issue not found");
|
||||
const [comment] = await tx.select({ id: issueComments.id, deletedAt: issueComments.deletedAt }).from(issueComments).where(and(
|
||||
eq(issueComments.id, input.commentId), eq(issueComments.companyId, input.companyId),
|
||||
eq(issueComments.issueId, input.issueId),
|
||||
)).for("update");
|
||||
if (!comment || comment.deletedAt) throw notFound("Issue comment not found");
|
||||
const before = input.referenceSummaryBefore ?? await references.listIssueReferenceSummary(input.issueId, connection);
|
||||
await references.syncComment(input.commentId, connection);
|
||||
const after = await references.listIssueReferenceSummary(input.issueId, connection);
|
||||
const diff = references.diffIssueReferenceSummary(before, after);
|
||||
const [existing] = await tx.select({ id: activityLog.id }).from(activityLog).where(and(
|
||||
eq(activityLog.companyId, input.companyId), eq(activityLog.entityType, "issue"),
|
||||
eq(activityLog.entityId, input.issueId), eq(activityLog.action, "issue.comment_added"),
|
||||
sql`${activityLog.details}->>'commentId' = ${input.commentId}`,
|
||||
));
|
||||
if (!existing) {
|
||||
const activity = input.activity(diff);
|
||||
await logActivity(connection, {
|
||||
...activity, companyId: input.companyId, action: "issue.comment_added",
|
||||
entityType: "issue", entityId: input.issueId,
|
||||
details: { ...activity.details, commentId: input.commentId },
|
||||
}, publications);
|
||||
}
|
||||
});
|
||||
for (const publication of publications) publishActivity(publication);
|
||||
}
|
||||
@@ -6750,6 +6750,17 @@ export async function readIssueCommentRunLogText(run: {
|
||||
return content;
|
||||
}
|
||||
|
||||
/** Share task-before-comment lock order with receipt completion and task deletion. */
|
||||
async function lockIssueForCommentMutation(tx: DbTransaction, commentId: string) {
|
||||
const [comment] = await tx.select({ issueId: issueComments.issueId, companyId: issueComments.companyId })
|
||||
.from(issueComments).where(eq(issueComments.id, commentId));
|
||||
if (!comment) return false;
|
||||
const [issue] = await tx.select({ id: issues.id }).from(issues).where(and(
|
||||
eq(issues.id, comment.issueId), eq(issues.companyId, comment.companyId),
|
||||
)).for("no key update");
|
||||
return Boolean(issue);
|
||||
}
|
||||
|
||||
export function issueService(db: Db) {
|
||||
const instanceSettings = instanceSettingsService(db);
|
||||
const treeControlSvc = issueTreeControlService(db);
|
||||
@@ -12569,6 +12580,7 @@ export function issueService(db: Db) {
|
||||
};
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
if (!(await lockIssueForCommentMutation(tx, commentId))) return null;
|
||||
const [comment] = await tx
|
||||
.delete(issueComments)
|
||||
.where(eq(issueComments.id, commentId))
|
||||
@@ -12602,6 +12614,7 @@ export function issueService(db: Db) {
|
||||
};
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
if (!(await lockIssueForCommentMutation(tx, commentId))) return null;
|
||||
const now = new Date();
|
||||
const [comment] = await tx
|
||||
.update(issueComments)
|
||||
|
||||
@@ -580,6 +580,12 @@ test.describe("Board send delivery refresh", () => {
|
||||
const sends: Record<string, unknown>[] = [];
|
||||
let attachmentId = "";
|
||||
let privateComment: { id: string } | undefined;
|
||||
// The lost send resends on its own; hold that resend until the waiting
|
||||
// state has been checked.
|
||||
let releaseResend!: () => void;
|
||||
const resendReleased = new Promise<void>((resolve) => {
|
||||
releaseResend = resolve;
|
||||
});
|
||||
await page.route(
|
||||
`**/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications`,
|
||||
async (route) => {
|
||||
@@ -598,7 +604,8 @@ test.describe("Board send delivery refresh", () => {
|
||||
);
|
||||
return route.abort("connectionreset");
|
||||
}
|
||||
if (sends.length === 2)
|
||||
if (sends.length === 2) {
|
||||
await resendReleased;
|
||||
return fulfill(
|
||||
route,
|
||||
{
|
||||
@@ -613,6 +620,7 @@ test.describe("Board send delivery refresh", () => {
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
return fulfill(
|
||||
route,
|
||||
{
|
||||
@@ -653,15 +661,15 @@ test.describe("Board send delivery refresh", () => {
|
||||
.getByRole("button", { name: "Send to channel", exact: true })
|
||||
.last()
|
||||
.click();
|
||||
// A lost response waits and resends on its own with the same identity.
|
||||
await expect(
|
||||
banner.getByText("Delivery result not confirmed", { exact: true }),
|
||||
banner.getByText("Sending when reconnected…", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
banner.getByRole("textbox", { name: "Board update" }),
|
||||
).toBeDisabled();
|
||||
await banner
|
||||
.getByRole("button", { name: "Retry safely", exact: true })
|
||||
.click();
|
||||
await expect.poll(() => sends.length).toBe(2);
|
||||
releaseResend();
|
||||
await expect(
|
||||
banner.getByText("Update was not sent", { exact: true }),
|
||||
).toBeVisible();
|
||||
@@ -735,7 +743,11 @@ test.describe("Board send delivery refresh", () => {
|
||||
const statusReads: string[] = [];
|
||||
let terminal = false;
|
||||
const publicationsPath = `/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications`;
|
||||
const storageKey = `paperclip:board-send:v1:${JSON.stringify([seed.companyId, issue.id, endpointId, conversationId])}`;
|
||||
const session = await json<{ user: { id: string } }>(
|
||||
await request.get("/api/auth/get-session"),
|
||||
"read receipt owner",
|
||||
);
|
||||
const storageKey = `paperclip:board-send:v2:${JSON.stringify([session.user.id, seed.companyId, issue.id, endpointId, conversationId])}`;
|
||||
await page.route("**/api/instance/settings/experimental", (route) =>
|
||||
fulfill(route, { enableChatConnectors: true }),
|
||||
);
|
||||
@@ -1028,6 +1040,10 @@ test.describe("Board send delivery refresh", () => {
|
||||
let canonicalAttachmentReadsAfterSend = 0;
|
||||
let canonicalCommentReadsAfterSend = 0;
|
||||
let status = "streaming";
|
||||
let releaseResend!: () => void;
|
||||
const resendReleased = new Promise<void>((resolve) => {
|
||||
releaseResend = resolve;
|
||||
});
|
||||
await page.route("**/api/instance/settings/experimental", (route) =>
|
||||
fulfill(route, { enableChatConnectors: true }),
|
||||
);
|
||||
@@ -1132,6 +1148,13 @@ test.describe("Board send delivery refresh", () => {
|
||||
await route.abort("failed");
|
||||
return;
|
||||
}
|
||||
if (outcome === "response_lost" && sends === 2) {
|
||||
// The automatic resend is still in flight when the page reloads;
|
||||
// the browser drops it.
|
||||
await resendReleased;
|
||||
await route.abort("failed").catch(() => {});
|
||||
return;
|
||||
}
|
||||
await fulfill(
|
||||
route,
|
||||
{ id: publicationId, state: "streaming", attempts: 1 },
|
||||
@@ -1182,21 +1205,30 @@ test.describe("Board send delivery refresh", () => {
|
||||
).toHaveCount(0);
|
||||
};
|
||||
if (outcome === "response_lost") {
|
||||
// The lost send resends on its own with the same identity.
|
||||
await expect(
|
||||
page.getByText("Sending when reconnected…", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => submittedPayloads.length).toBe(2);
|
||||
expect(submittedPayloads[1]).toEqual(submittedPayloads[0]);
|
||||
// A send restored after reload has an unknown outcome and waits for
|
||||
// an explicit, same-identity retry.
|
||||
await page.reload();
|
||||
releaseResend();
|
||||
await expect(
|
||||
page.getByText("Delivery result not confirmed", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Board update", exact: true }),
|
||||
).toBeDisabled();
|
||||
await expectRetainedFiles();
|
||||
expect(sends).toBe(1);
|
||||
expect(sends).toBe(2);
|
||||
expect(reads).toBe(0);
|
||||
await page
|
||||
.getByRole("button", { name: "Retry safely", exact: true })
|
||||
.click();
|
||||
await expect.poll(() => submittedPayloads.length).toBe(2);
|
||||
expect(submittedPayloads[1]).toEqual(submittedPayloads[0]);
|
||||
await expect.poll(() => submittedPayloads.length).toBe(3);
|
||||
expect(submittedPayloads[2]).toEqual(submittedPayloads[0]);
|
||||
} else {
|
||||
await expect(
|
||||
page.getByText("Publishing to channel", { exact: true }).first(),
|
||||
@@ -1258,7 +1290,10 @@ test.describe("Board send delivery refresh", () => {
|
||||
page.getByRole("textbox", { name: "Board update", exact: true }),
|
||||
).toHaveCount(0, { timeout: 8_000 });
|
||||
}
|
||||
expect(sends).toBe(outcome === "response_lost" ? 2 : 1);
|
||||
// response_lost: the lost send, its automatic resend, and the explicit
|
||||
// retry after reload, all with one identity.
|
||||
const expectedSends = outcome === "response_lost" ? 3 : 1;
|
||||
expect(sends).toBe(expectedSends);
|
||||
expect(canonicalCommentReadsAfterSend).toBeGreaterThan(0);
|
||||
expect(canonicalAttachmentReadsAfterSend).toBeGreaterThan(0);
|
||||
await expect(
|
||||
@@ -1280,7 +1315,7 @@ test.describe("Board send delivery refresh", () => {
|
||||
await expect(
|
||||
newFiles.getByRole("checkbox", { name: "internal-only.txt" }),
|
||||
).toBeEnabled();
|
||||
expect(sends).toBe(outcome === "response_lost" ? 2 : 1);
|
||||
expect(sends).toBe(expectedSends);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { test, expect, type APIResponse } from "@playwright/test";
|
||||
|
||||
async function json(response: APIResponse) {
|
||||
const body = await response.text();
|
||||
expect(response.ok(), `${response.url()}: ${response.status()} ${body}`).toBe(true);
|
||||
return JSON.parse(body);
|
||||
}
|
||||
|
||||
test("a task title survives an outage and saves after server recovery", async ({ page, request }) => {
|
||||
const company = await json(await request.post("/api/companies", { data: { name: `Write recovery ${Date.now()}` } }));
|
||||
try {
|
||||
const issue = await json(await request.post(`/api/companies/${company.id}/issues`, {
|
||||
data: { title: "Original task title", status: "backlog" },
|
||||
}));
|
||||
const other = await json(await request.post(`/api/companies/${company.id}/issues`, {
|
||||
data: { title: "Another task stays unchanged", status: "backlog" },
|
||||
}));
|
||||
await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`);
|
||||
const header = page.getByTestId("issue-detail-header");
|
||||
await expect(header.getByRole("heading", { name: issue.title, exact: true })).toBeVisible();
|
||||
|
||||
let unavailable = true;
|
||||
let failedWrites = 0;
|
||||
await page.route("**/api/**", async route => {
|
||||
if (!unavailable) return route.continue();
|
||||
if (route.request().method() === "PATCH" && [`/api/issues/${issue.id}`, `/api/issues/${issue.identifier}`].includes(new URL(route.request().url()).pathname)) {
|
||||
failedWrites += 1;
|
||||
}
|
||||
if (!failedWrites) return route.continue();
|
||||
await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "tenant_app_unavailable" }) });
|
||||
});
|
||||
await header.getByRole("heading", { name: issue.title, exact: true }).click();
|
||||
const title = header.locator("textarea");
|
||||
await title.fill("A title retained through the outage");
|
||||
await title.press("Enter");
|
||||
await expect.poll(() => failedWrites).toBeGreaterThan(0);
|
||||
await expect(page.locator('[data-connection-status="reconnecting"]')).toBeVisible();
|
||||
expect((await json(await request.get(`/api/issues/${issue.id}`))).title).toBe(issue.title);
|
||||
await expect(header).toBeVisible();
|
||||
|
||||
unavailable = false;
|
||||
await expect.poll(async () => (await json(await request.get(`/api/issues/${issue.id}`))).title,
|
||||
{ timeout: 30_000 }).toBe("A title retained through the outage");
|
||||
expect((await json(await request.get(`/api/issues/${other.id}`))).title).toBe(other.title);
|
||||
await page.reload();
|
||||
await expect(header.getByRole("heading", { name: "A title retained through the outage", exact: true })).toBeVisible();
|
||||
expect((await json(await request.get(`/api/issues/${issue.id}`))).status).toBe("backlog");
|
||||
} finally {
|
||||
await request.patch(`/api/companies/${company.id}`, { data: { status: "archived" } });
|
||||
}
|
||||
});
|
||||
|
||||
test("two cached tasks keep separate title saves while the browser is offline", async ({ page, request }) => {
|
||||
test.setTimeout(120_000);
|
||||
const company = await json(await request.post("/api/companies", { data: { name: `Queued writes ${Date.now()}` } }));
|
||||
try {
|
||||
const first = await json(await request.post(`/api/companies/${company.id}/issues`, { data: { title: "First queued task", status: "todo" } }));
|
||||
const second = await json(await request.post(`/api/companies/${company.id}/issues`, { data: { title: "Second queued task", status: "todo" } }));
|
||||
await page.goto(`/${company.issuePrefix}/issues/${second.id}`);
|
||||
await expect(page.getByRole("heading", { name: second.title, exact: true })).toBeVisible();
|
||||
await page.getByRole("link", { name: "Tasks", exact: true }).first().click();
|
||||
await page.getByRole("link").filter({ hasText: first.title }).first().click();
|
||||
const header = page.getByTestId("issue-detail-header");
|
||||
await expect(header.getByRole("heading", { name: first.title, exact: true })).toBeVisible();
|
||||
const dismissAnnouncement = page.getByRole("button", { name: "Dismiss announcement" });
|
||||
if (await dismissAnnouncement.isVisible()) await dismissAnnouncement.click();
|
||||
const secondLink = page.getByRole("link").filter({ hasText: second.title }).first();
|
||||
await expect(secondLink).toBeVisible();
|
||||
await page.context().setOffline(true);
|
||||
await expect(page.locator('[data-connection-status="offline"]')).toBeVisible();
|
||||
await header.getByRole("heading", { name: first.title, exact: true }).click();
|
||||
await header.locator("textarea").fill("First title retained offline");
|
||||
await header.locator("textarea").press("Enter");
|
||||
await secondLink.focus();
|
||||
await secondLink.press("Enter", { timeout: 5_000 });
|
||||
await expect(page).toHaveURL(new RegExp(`${second.id}$`));
|
||||
await expect(header.getByRole("heading", { name: second.title, exact: true })).toBeVisible();
|
||||
await header.getByRole("heading", { name: second.title, exact: true }).click();
|
||||
await expect(header.locator("textarea")).toHaveValue(second.title);
|
||||
await header.locator("textarea").fill("Second title retained offline");
|
||||
await header.locator("textarea").press("Enter");
|
||||
expect((await json(await request.get(`/api/issues/${first.id}`))).title).toBe(first.title);
|
||||
expect((await json(await request.get(`/api/issues/${second.id}`))).title).toBe(second.title);
|
||||
await page.context().setOffline(false);
|
||||
await expect.poll(async () => (await json(await request.get(`/api/issues/${first.id}`))).title, { timeout: 30_000 }).toBe("First title retained offline");
|
||||
await expect.poll(async () => (await json(await request.get(`/api/issues/${second.id}`))).title, { timeout: 30_000 }).toBe("Second title retained offline");
|
||||
await page.reload();
|
||||
await expect(header.getByRole("heading", { name: "Second title retained offline", exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await page.context().setOffline(false).catch(() => {});
|
||||
await request.patch(`/api/companies/${company.id}`, { data: { status: "archived" } });
|
||||
}
|
||||
});
|
||||
|
||||
test("a queued startup save survives the first session lookup during an API outage", async ({ page, request }) => {
|
||||
const company = await json(await request.post("/api/companies", { data: { name: `Startup recovery ${Date.now()}` } }));
|
||||
const session = await json(await request.get("/api/auth/get-session"));
|
||||
let releaseSession!: () => void;
|
||||
const sessionReady = new Promise<void>(resolve => { releaseSession = resolve; });
|
||||
const savedTitle = "Saved through first session lookup";
|
||||
let apiUnavailable = false;
|
||||
try {
|
||||
const issue = await json(await request.post(`/api/companies/${company.id}/issues`, {
|
||||
data: { title: "Startup original title", status: "backlog" },
|
||||
}));
|
||||
await page.route("**/api/**", async route => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (apiUnavailable && (path === "/api/health" || (route.request().method() === "PATCH" && path.startsWith("/api/issues/")))) {
|
||||
return route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "tenant_app_unavailable" }) });
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
await page.route("**/api/auth/get-session", async route => {
|
||||
await sessionReady;
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(session) });
|
||||
});
|
||||
await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`);
|
||||
const header = page.getByTestId("issue-detail-header");
|
||||
await expect(header.getByRole("heading", { name: issue.title, exact: true })).toBeVisible();
|
||||
apiUnavailable = true;
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("offline")));
|
||||
await expect(page.locator('[data-connection-status="offline"]')).toBeVisible();
|
||||
await header.getByRole("heading", { name: issue.title, exact: true }).click();
|
||||
await header.locator("textarea").fill(savedTitle);
|
||||
await header.locator("textarea").press("Enter");
|
||||
expect((await json(await request.get(`/api/issues/${issue.id}`))).title).toBe(issue.title);
|
||||
releaseSession();
|
||||
await expect.poll(() => page.evaluate(({ owner, title }) => Object.keys(localStorage).some(key =>
|
||||
key.startsWith(`paperclip:inline-draft:v2:${owner}:`) && JSON.parse(localStorage.getItem(key) ?? "null")?.draft === title),
|
||||
{ owner: session.user.id, title: savedTitle })).toBe(true);
|
||||
apiUnavailable = false;
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("online")));
|
||||
await expect.poll(async () => (await json(await request.get(`/api/issues/${issue.id}`))).title,
|
||||
{ timeout: 15_000 }).toBe(savedTitle);
|
||||
await page.reload();
|
||||
await expect(header.getByRole("heading", { name: savedTitle, exact: true })).toBeVisible();
|
||||
} finally {
|
||||
releaseSession();
|
||||
apiUnavailable = false;
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("online"))).catch(() => {});
|
||||
await request.patch(`/api/companies/${company.id}`, { data: { status: "archived" } });
|
||||
}
|
||||
});
|
||||
|
||||
test("replaying an accepted comment does not reopen completed work", async ({ page, request }) => {
|
||||
const company = await json(await request.post("/api/companies", { data: { name: `Comment recovery ${Date.now()}` } }));
|
||||
try {
|
||||
const issue = await json(await request.post(`/api/companies/${company.id}/issues`, {
|
||||
data: { title: "Completed work stays completed", status: "backlog", assigneeAgentId: null, assigneeUserId: null },
|
||||
}));
|
||||
const body = "A comment whose response was lost";
|
||||
const clientRequestId = crypto.randomUUID();
|
||||
const first = await json(await request.post(`/api/issues/${issue.id}/comments`, { data: { body, clientRequestId } }));
|
||||
const agent = await json(await request.post(`/api/companies/${company.id}/agents`, {
|
||||
data: { name: "Completed comment worker", role: "engineer", adapterType: "process", runner: "legacy",
|
||||
adapterConfig: { command: "/usr/bin/true" }, runtimeConfig: { heartbeat: { enabled: false } } },
|
||||
}));
|
||||
await json(await request.patch(`/api/issues/${issue.id}`, { data: { status: "done", assigneeAgentId: agent.id } }));
|
||||
const receipt = await json(await request.post(`/api/issues/${issue.id}/comments`, { data: { body, clientRequestId } }));
|
||||
expect(receipt.id).toBe(first.id);
|
||||
expect((await json(await request.get(`/api/issues/${issue.id}`))).status).toBe("done");
|
||||
const conflict = await request.post(`/api/issues/${issue.id}/comments`, { data: { body: "Different request", clientRequestId } });
|
||||
expect(conflict.status()).toBe(409);
|
||||
expect((await json(await request.get(`/api/issues/${issue.id}`))).status).toBe("done");
|
||||
const comments = await json(await request.get(`/api/issues/${issue.id}/comments`));
|
||||
expect(comments.filter((comment: { clientRequestId?: string }) => comment.clientRequestId === clientRequestId)).toHaveLength(1);
|
||||
const activity = await json(await request.get(`/api/issues/${issue.id}/activity`));
|
||||
expect(activity.filter((entry: { action: string; details?: { commentId?: string } }) =>
|
||||
entry.action === "issue.comment_added" && entry.details?.commentId === first.id)).toHaveLength(1);
|
||||
await page.goto(`/${company.issuePrefix}/issues/${issue.id}`);
|
||||
await expect(page.getByRole("heading", { name: issue.title, exact: true })).toBeVisible();
|
||||
await expect(page.getByText(body, { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await request.patch(`/api/companies/${company.id}`, { data: { status: "archived" } });
|
||||
}
|
||||
});
|
||||
|
||||
test("startup work retries a failed session lookup while other APIs stay healthy", async ({ page, request }) => {
|
||||
const company = await json(await request.post("/api/companies", { data: { name: `Session recovery ${Date.now()}` } }));
|
||||
const session = await json(await request.get("/api/auth/get-session"));
|
||||
let healthy = false, saving = false, failedAfterSave = 0;
|
||||
try {
|
||||
const issue = await json(await request.post(`/api/companies/${company.id}/issues`, {
|
||||
data: { title: "Before session recovery", status: "backlog" },
|
||||
}));
|
||||
await page.route("**/api/auth/get-session", async route => {
|
||||
if (healthy) return route.fulfill({ status: 200, json: session });
|
||||
if (saving) failedAfterSave += 1;
|
||||
await route.fulfill({ status: 500, json: { error: "session_lookup_failed" } });
|
||||
});
|
||||
await page.goto(`/${company.issuePrefix}/issues/${issue.id}`);
|
||||
const header = page.getByTestId("issue-detail-header");
|
||||
await header.getByRole("heading", { name: issue.title, exact: true }).click();
|
||||
await header.locator("textarea").fill("Saved after session recovery");
|
||||
saving = true;
|
||||
await header.locator("textarea").press("Enter");
|
||||
await expect.poll(() => failedAfterSave).toBeGreaterThan(0);
|
||||
healthy = true;
|
||||
await expect.poll(async () => (await json(await request.get(`/api/issues/${issue.id}`))).title,
|
||||
{ timeout: 15_000 }).toBe("Saved after session recovery");
|
||||
await page.reload();
|
||||
await expect(header.getByRole("heading", { name: "Saved after session recovery", exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await request.patch(`/api/companies/${company.id}`, { data: { status: "archived" } });
|
||||
}
|
||||
});
|
||||
@@ -11,7 +11,8 @@ vi.mock("./client", async (importOriginal) => ({
|
||||
api: mockApi,
|
||||
}));
|
||||
|
||||
import { issuesApi } from "./issues";
|
||||
import type { IssueThreadInteraction } from "@paperclipai/shared";
|
||||
import { acceptedResolutionMatches, answeredResolutionMatches, issuesApi } from "./issues";
|
||||
import { ApiError } from "./client";
|
||||
import { CommentSubmissionUnknownError } from "../lib/comment-submit-result";
|
||||
|
||||
@@ -244,3 +245,100 @@ describe("issuesApi.list", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interaction resolution replay", () => {
|
||||
const alreadyResolved = () =>
|
||||
new ApiError("Interaction has already been resolved", 409, {
|
||||
error: "Interaction has already been resolved",
|
||||
code: "interaction_already_resolved",
|
||||
});
|
||||
const outage = () => new ApiError("Paperclip is restarting", 503, { error: "tenant_app_unavailable" });
|
||||
|
||||
beforeEach(() => {
|
||||
mockApi.get.mockReset();
|
||||
mockApi.post.mockReset();
|
||||
});
|
||||
|
||||
it("treats already-resolved after a transient failure as success when the resolution matches", async () => {
|
||||
const accepted = { id: "interaction-1", status: "accepted" };
|
||||
mockApi.post.mockRejectedValueOnce(outage()).mockRejectedValueOnce(alreadyResolved());
|
||||
mockApi.get.mockResolvedValue([accepted]);
|
||||
await expect(issuesApi.acceptInteraction("issue-1", "interaction-1")).rejects.toMatchObject({ status: 503 });
|
||||
await expect(issuesApi.acceptInteraction("issue-1", "interaction-1")).resolves.toEqual(accepted);
|
||||
expect(mockApi.get).toHaveBeenCalledWith("/issues/issue-1/interactions");
|
||||
});
|
||||
|
||||
it("retries the verification when reading the card back also hits the outage", async () => {
|
||||
const accepted = { id: "interaction-6", status: "accepted" };
|
||||
mockApi.post
|
||||
.mockRejectedValueOnce(outage())
|
||||
.mockRejectedValueOnce(alreadyResolved())
|
||||
.mockRejectedValueOnce(alreadyResolved());
|
||||
mockApi.get.mockRejectedValueOnce(outage()).mockResolvedValue([accepted]);
|
||||
await expect(issuesApi.acceptInteraction("issue-1", "interaction-6")).rejects.toMatchObject({ status: 503 });
|
||||
// The decision landed, but the read-back is still down: stay retryable.
|
||||
await expect(issuesApi.acceptInteraction("issue-1", "interaction-6")).rejects.toMatchObject({ status: 503 });
|
||||
await expect(issuesApi.acceptInteraction("issue-1", "interaction-6")).resolves.toEqual(accepted);
|
||||
});
|
||||
|
||||
it("keeps the conflict when the card was resolved differently", async () => {
|
||||
mockApi.post.mockRejectedValueOnce(outage()).mockRejectedValueOnce(alreadyResolved());
|
||||
mockApi.get.mockResolvedValue([{ id: "interaction-2", status: "rejected" }]);
|
||||
await expect(issuesApi.acceptInteraction("issue-1", "interaction-2")).rejects.toBeInstanceOf(ApiError);
|
||||
await expect(issuesApi.acceptInteraction("issue-1", "interaction-2")).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
|
||||
it("keeps the conflict when someone else accepted different checkbox options", async () => {
|
||||
const savedByOther = {
|
||||
id: "interaction-4",
|
||||
kind: "request_checkbox_confirmation",
|
||||
status: "accepted",
|
||||
result: { version: 1, outcome: "accepted", selectedOptionIds: ["b"] },
|
||||
};
|
||||
mockApi.post.mockRejectedValueOnce(outage()).mockRejectedValueOnce(alreadyResolved());
|
||||
mockApi.get.mockResolvedValue([savedByOther]);
|
||||
await expect(issuesApi.acceptInteraction("issue-1", "interaction-4", { selectedOptionIds: ["a"] })).rejects.toMatchObject({ status: 503 });
|
||||
await expect(issuesApi.acceptInteraction("issue-1", "interaction-4", { selectedOptionIds: ["a"] })).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
|
||||
it("treats already-resolved as success when the saved checkbox options are ours", async () => {
|
||||
const saved = {
|
||||
id: "interaction-5",
|
||||
kind: "request_checkbox_confirmation",
|
||||
status: "accepted",
|
||||
result: { version: 1, outcome: "accepted", selectedOptionIds: ["b", "a"] },
|
||||
};
|
||||
mockApi.post.mockRejectedValueOnce(outage()).mockRejectedValueOnce(alreadyResolved());
|
||||
mockApi.get.mockResolvedValue([saved]);
|
||||
await expect(issuesApi.acceptInteraction("issue-1", "interaction-5", { selectedOptionIds: ["a", "b"] })).rejects.toMatchObject({ status: 503 });
|
||||
await expect(issuesApi.acceptInteraction("issue-1", "interaction-5", { selectedOptionIds: ["a", "b"] })).resolves.toEqual(saved);
|
||||
});
|
||||
|
||||
it("matches answers and suggested-task selections exactly", () => {
|
||||
const answers = [{ questionId: "q1", optionIds: ["x"], otherText: null }];
|
||||
const answered = {
|
||||
kind: "ask_user_questions",
|
||||
status: "answered",
|
||||
result: { version: 1, answers },
|
||||
} as unknown as IssueThreadInteraction;
|
||||
expect(answeredResolutionMatches(answered, answers)).toBe(true);
|
||||
expect(answeredResolutionMatches(answered, [{ questionId: "q1", optionIds: ["y"] }])).toBe(false);
|
||||
|
||||
const suggested = {
|
||||
kind: "suggest_tasks",
|
||||
status: "accepted",
|
||||
payload: { tasks: [{ clientKey: "t1" }, { clientKey: "t2" }] },
|
||||
result: { version: 1, skippedClientKeys: ["t2"] },
|
||||
} as unknown as IssueThreadInteraction;
|
||||
expect(acceptedResolutionMatches(suggested, { selectedClientKeys: ["t1"] })).toBe(true);
|
||||
expect(acceptedResolutionMatches(suggested, { selectedClientKeys: ["t1", "t2"] })).toBe(false);
|
||||
expect(acceptedResolutionMatches(suggested, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the conflict when no transient failure came first", async () => {
|
||||
mockApi.post.mockRejectedValueOnce(alreadyResolved());
|
||||
mockApi.get.mockResolvedValue([{ id: "interaction-3", status: "answered" }]);
|
||||
await expect(issuesApi.respondToInteraction("issue-1", "interaction-3", { answers: [] })).rejects.toMatchObject({ status: 409 });
|
||||
expect(mockApi.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+131
-14
@@ -46,6 +46,8 @@ import type {
|
||||
} from "@paperclipai/shared";
|
||||
import { api, ApiError, detachInflightGet, type RequestOptions } from "./client";
|
||||
import { CommentSubmissionUnknownError } from "../lib/comment-submit-result";
|
||||
import { classifySendFailure } from "../lib/pending-send";
|
||||
import { interactionResolutionErrorCode } from "../lib/interaction-resolution-error";
|
||||
|
||||
function hasCommentReceipt(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
@@ -76,6 +78,104 @@ async function confirmedCommentResponse<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolutions that failed transiently in this tab. A resolution may land on
|
||||
* the server and lose its response; resending it then gets
|
||||
* `interaction_already_resolved`. That is our own answer, not a conflict, but
|
||||
* only when the saved resolution is exactly the one we sent: someone else may
|
||||
* have resolved the card meanwhile with the same status and different choices.
|
||||
*/
|
||||
const transientlyFailedResolutions = new Set<string>();
|
||||
|
||||
function sameSet(left: readonly string[] | null | undefined, right: readonly string[] | null | undefined): boolean {
|
||||
const a = new Set(left ?? []);
|
||||
const b = new Set(right ?? []);
|
||||
return a.size === b.size && [...a].every((value) => b.has(value));
|
||||
}
|
||||
|
||||
function sameText(left: string | null | undefined, right: string | null | undefined): boolean {
|
||||
return (left?.trim() || null) === (right?.trim() || null);
|
||||
}
|
||||
|
||||
type AcceptInteractionInput = {
|
||||
selectedClientKeys?: string[];
|
||||
selectedOptionIds?: string[];
|
||||
rememberAction?: boolean;
|
||||
};
|
||||
|
||||
/** Whether a stored accepted resolution records exactly this accept request. */
|
||||
export function acceptedResolutionMatches(current: IssueThreadInteraction, data: AcceptInteractionInput | undefined): boolean {
|
||||
if (current.status !== "accepted") return false;
|
||||
if (current.kind === "suggest_tasks") {
|
||||
// The server skips every suggested task that was not selected.
|
||||
const allKeys = current.payload.tasks.map((task) => task.clientKey);
|
||||
const selected = new Set(data?.selectedClientKeys ?? allKeys);
|
||||
return sameSet(current.result?.skippedClientKeys, allKeys.filter((key) => !selected.has(key)));
|
||||
}
|
||||
if (current.kind === "request_checkbox_confirmation") {
|
||||
return sameSet(current.result?.selectedOptionIds, data?.selectedOptionIds);
|
||||
}
|
||||
return !data?.selectedClientKeys && !data?.selectedOptionIds;
|
||||
}
|
||||
|
||||
/** Whether a stored rejection records exactly this reject request. */
|
||||
export function rejectedResolutionMatches(current: IssueThreadInteraction, reason: string | undefined): boolean {
|
||||
if (current.status !== "rejected") return false;
|
||||
const result = (current.result ?? null) as { reason?: string | null; rejectionReason?: string | null } | null;
|
||||
return sameText(current.kind === "suggest_tasks" ? result?.rejectionReason : result?.reason, reason);
|
||||
}
|
||||
|
||||
/** Whether stored answers record exactly these answers. */
|
||||
export function answeredResolutionMatches(current: IssueThreadInteraction, answers: AskUserQuestionsAnswer[]): boolean {
|
||||
if (current.status !== "answered" || current.kind !== "ask_user_questions") return false;
|
||||
const saved = current.result?.answers ?? [];
|
||||
if (saved.length !== answers.length) return false;
|
||||
return answers.every((answer) => {
|
||||
const match = saved.find((row) => row.questionId === answer.questionId);
|
||||
return Boolean(match && sameSet(match.optionIds, answer.optionIds) && sameText(match.otherText, answer.otherText));
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveInteractionOnce(
|
||||
issueId: string,
|
||||
interactionId: string,
|
||||
/** Identifies this exact request; a different answer is a different resolution. */
|
||||
requestFingerprint: string,
|
||||
matchesRequest: (current: IssueThreadInteraction) => boolean,
|
||||
request: () => Promise<IssueThreadInteraction>,
|
||||
): Promise<IssueThreadInteraction> {
|
||||
const key = `${issueId}:${interactionId}:${requestFingerprint}`;
|
||||
try {
|
||||
const result = await request();
|
||||
transientlyFailedResolutions.delete(key);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (classifySendFailure(error) === "pending") {
|
||||
transientlyFailedResolutions.add(key);
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
transientlyFailedResolutions.has(key) &&
|
||||
interactionResolutionErrorCode(error) === "interaction_already_resolved"
|
||||
) {
|
||||
const current = await api
|
||||
.get<IssueThreadInteraction[]>(`/issues/${issueId}/interactions`)
|
||||
.then((rows) => rows.find((row) => row.id === interactionId) ?? null)
|
||||
.catch((readError: unknown) => {
|
||||
// A temporary read failure is no verdict: let it escape so the
|
||||
// replay policy retries, and the next attempt verifies again.
|
||||
if (classifySendFailure(readError) === "pending") throw readError;
|
||||
return null;
|
||||
});
|
||||
if (current && matchesRequest(current)) {
|
||||
transientlyFailedResolutions.delete(key);
|
||||
return current;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export type IssueUpdateResponse = Issue & {
|
||||
comment?: IssueComment | null;
|
||||
changes: IssueChanges;
|
||||
@@ -482,20 +582,30 @@ export const issuesApi = {
|
||||
acceptInteraction: (
|
||||
id: string,
|
||||
interactionId: string,
|
||||
data?: {
|
||||
selectedClientKeys?: string[];
|
||||
selectedOptionIds?: string[];
|
||||
rememberAction?: boolean;
|
||||
},
|
||||
data?: AcceptInteractionInput,
|
||||
) =>
|
||||
api.post<IssueThreadInteraction>(
|
||||
`/issues/${id}/interactions/${interactionId}/accept`,
|
||||
data ?? {},
|
||||
resolveInteractionOnce(
|
||||
id,
|
||||
interactionId,
|
||||
`accept:${JSON.stringify([data?.selectedClientKeys ?? null, data?.selectedOptionIds ?? null])}`,
|
||||
(current) => acceptedResolutionMatches(current, data),
|
||||
() =>
|
||||
api.post<IssueThreadInteraction>(
|
||||
`/issues/${id}/interactions/${interactionId}/accept`,
|
||||
data ?? {},
|
||||
),
|
||||
),
|
||||
rejectInteraction: (id: string, interactionId: string, reason?: string) =>
|
||||
api.post<IssueThreadInteraction>(
|
||||
`/issues/${id}/interactions/${interactionId}/reject`,
|
||||
reason ? { reason } : {},
|
||||
resolveInteractionOnce(
|
||||
id,
|
||||
interactionId,
|
||||
`reject:${JSON.stringify(reason ?? null)}`,
|
||||
(current) => rejectedResolutionMatches(current, reason),
|
||||
() =>
|
||||
api.post<IssueThreadInteraction>(
|
||||
`/issues/${id}/interactions/${interactionId}/reject`,
|
||||
reason ? { reason } : {},
|
||||
),
|
||||
),
|
||||
cancelInteraction: (id: string, interactionId: string, reason?: string) =>
|
||||
api.post<IssueThreadInteraction>(
|
||||
@@ -517,9 +627,16 @@ export const issuesApi = {
|
||||
summaryMarkdown?: string | null;
|
||||
},
|
||||
) =>
|
||||
api.post<IssueThreadInteraction>(
|
||||
`/issues/${id}/interactions/${interactionId}/respond`,
|
||||
data,
|
||||
resolveInteractionOnce(
|
||||
id,
|
||||
interactionId,
|
||||
`respond:${JSON.stringify(data.answers)}`,
|
||||
(current) => answeredResolutionMatches(current, data.answers),
|
||||
() =>
|
||||
api.post<IssueThreadInteraction>(
|
||||
`/issues/${id}/interactions/${interactionId}/respond`,
|
||||
data,
|
||||
),
|
||||
),
|
||||
submitInteractionVerdicts: (
|
||||
id: string,
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
import { act, forwardRef, useImperativeHandle, useRef, type ReactNode } from "react";
|
||||
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";
|
||||
|
||||
const mockSession = vi.hoisted(() => ({ userId: "user-1", pending: null as Promise<{ user: { id: string } } | null> | null }));
|
||||
vi.mock("../api/auth", () => ({
|
||||
authApi: { getSession: () => mockSession.pending ?? Promise.resolve({ user: { id: mockSession.userId } }) },
|
||||
}));
|
||||
|
||||
vi.mock("./MarkdownEditor", () => ({
|
||||
MarkdownEditor: forwardRef<
|
||||
@@ -32,6 +39,14 @@ vi.mock("./MarkdownBody", () => ({
|
||||
|
||||
import { InlineEditor, queueContainedBlurCommit } from "./InlineEditor";
|
||||
|
||||
/** Render as a signed-in user: stored drafts belong to one account. */
|
||||
function signedIn(userId: string, node: ReactNode) {
|
||||
mockSession.userId = userId;
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: userId } });
|
||||
return <QueryClientProvider client={client}>{node}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
/** Enter multiline edit mode by clicking the preview surface. */
|
||||
function enterMultilineEdit(container: HTMLDivElement) {
|
||||
const preview = container.querySelector<HTMLDivElement>('[data-testid="multiline-md-preview"]');
|
||||
@@ -111,6 +126,203 @@ describe("InlineEditor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a single-line edit open with no unhandled rejection when the save fails", async () => {
|
||||
const unhandled = vi.fn();
|
||||
window.addEventListener("unhandledrejection", unhandled);
|
||||
process.on("unhandledRejection", unhandled);
|
||||
const onSave = vi.fn().mockRejectedValue(new Error("Title is too long"));
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(<InlineEditor value="hello" onSave={onSave} />);
|
||||
});
|
||||
act(() => {
|
||||
container.querySelector("span")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
const textarea = container.querySelector("textarea")!;
|
||||
act(() => setNativeTextareaValue(textarea, "hello world"));
|
||||
await act(async () => {
|
||||
textarea.blur();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(onSave).toHaveBeenCalledWith("hello world");
|
||||
// The text stays in the editor instead of snapping back to the old value.
|
||||
expect(container.querySelector("textarea")?.value).toBe("hello world");
|
||||
expect(unhandled).not.toHaveBeenCalled();
|
||||
window.removeEventListener("unhandledrejection", unhandled);
|
||||
process.off("unhandledRejection", unhandled);
|
||||
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);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(signedIn("user-1", <InlineEditor value="Original" multiline draftKey="issue-1:description" onSave={onSave} />));
|
||||
});
|
||||
expect(container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]')?.value).toBe("Typed before reload");
|
||||
expect(onSave).toHaveBeenCalledWith("Typed before reload");
|
||||
act(() => root.unmount());
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("restores the next task's stored edit without remounting the editor", async () => {
|
||||
localStorage.setItem("paperclip:inline-draft:v2:user-1:issue-b:description", JSON.stringify({ version: 1, draft: "Recovered B", base: "Original B" }));
|
||||
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={`issue-${id}:description`} onSave={onSave} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
try {
|
||||
await act(async () => renderTask("a", saveA));
|
||||
await act(async () => renderTask("b", saveB));
|
||||
expect(container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]')?.value).toBe("Recovered B");
|
||||
expect(saveB).toHaveBeenCalledWith("Recovered B");
|
||||
expect(saveA).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
localStorage.clear();
|
||||
client.clear();
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(signedIn("user-1", <InlineEditor value="Changed by an agent" multiline draftKey="issue-2:description" onSave={onSave} />));
|
||||
});
|
||||
expect(container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]')?.value).toBe("My old edit");
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("neither shows nor saves another account's unsaved edit", async () => {
|
||||
localStorage.setItem("paperclip:inline-draft:v2:user-1:issue-3:description", JSON.stringify({ version: 1, draft: "Unpublished by user 1", base: "Original" }));
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(signedIn("user-2", <InlineEditor value="Original" multiline draftKey="issue-3:description" onSave={onSave} />));
|
||||
});
|
||||
expect(container.textContent).not.toContain("Unpublished by user 1");
|
||||
expect(container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]')?.value ?? "").not.toBe("Unpublished by user 1");
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it.each(["user-1", "signed-out"])("keeps typing through the first session lookup (%s)", async (owner) => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem(`paperclip:inline-draft:v2:${owner}:initial-session`, JSON.stringify({ version: 1, draft: "Earlier saved draft", base: "Original" }));
|
||||
let finish!: (session: { user: { id: string } } | null) => void;
|
||||
mockSession.pending = new Promise(resolve => { finish = resolve; });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<InlineEditor value="Original" draftKey="initial-session" onSave={onSave} />
|
||||
</QueryClientProvider>,
|
||||
));
|
||||
act(() => container.querySelector("span")!.click());
|
||||
const input = container.querySelector("textarea")!;
|
||||
act(() => setNativeTextareaValue(input, "Typing while session loads"));
|
||||
await act(async () => {
|
||||
finish(owner === "signed-out" ? null : { user: { id: owner } });
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(container.querySelector("textarea")).toBe(input);
|
||||
expect(input.value).toBe("Typing while session loads");
|
||||
await vi.waitFor(async () => {
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
||||
expect(localStorage.getItem(`paperclip:inline-draft:v2:${owner}:initial-session`)).toContain("Typing while session loads");
|
||||
});
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
client.clear();
|
||||
localStorage.clear();
|
||||
mockSession.pending = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not restore typing cancelled before the first session lookup", async () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem("paperclip:inline-draft:v2:user-1:cancel-before-session", JSON.stringify({ version: 1, draft: "Original", base: "Original" }));
|
||||
let finish!: (session: { user: { id: string } }) => void;
|
||||
mockSession.pending = new Promise(resolve => { finish = resolve; });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<InlineEditor value="Original" draftKey="cancel-before-session" onSave={vi.fn()} />
|
||||
</QueryClientProvider>,
|
||||
));
|
||||
act(() => container.querySelector("span")!.click());
|
||||
act(() => setNativeTextareaValue(container.querySelector("textarea")!, "Cancelled private text"));
|
||||
act(() => container.querySelector("textarea")!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })));
|
||||
await act(async () => {
|
||||
finish({ user: { id: "user-1" } });
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(container.textContent).not.toContain("Cancelled private text");
|
||||
await vi.waitFor(async () => {
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
||||
expect(localStorage.getItem("paperclip:inline-draft:v2:user-1:cancel-before-session")).toBeNull();
|
||||
});
|
||||
} finally {
|
||||
act(() => root.unmount()); client.clear(); localStorage.clear(); mockSession.pending = null;
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])("isolates a mounted private edit after an account change (multiline=%s)", async (multiline) => {
|
||||
mockSession.userId = "user-1";
|
||||
localStorage.clear();
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<InlineEditor value="Original" multiline={multiline} defaultEditing={multiline} draftKey="private-switch" onSave={onSave} />
|
||||
</QueryClientProvider>,
|
||||
));
|
||||
if (!multiline) act(() => container.querySelector("span")!.click());
|
||||
act(() => setNativeTextareaValue(container.querySelector("textarea")!, "Private account A draft"));
|
||||
const storedA = localStorage.getItem("paperclip:inline-draft:v2:user-1:private-switch");
|
||||
expect(storedA).toContain("Private account A draft");
|
||||
mockSession.userId = "user-2";
|
||||
await act(async () => {
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } });
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(container.querySelector("textarea")?.value ?? "").not.toContain("Private account A draft");
|
||||
expect(container.textContent).not.toContain("Private account A draft");
|
||||
expect(localStorage.getItem("paperclip:inline-draft:v2:user-1:private-switch")).toBe(storedA);
|
||||
expect(localStorage.getItem("paperclip:inline-draft:v2:user-2:private-switch")).toBeNull();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
if (!multiline) act(() => container.querySelector("span")!.click());
|
||||
act(() => setNativeTextareaValue(container.querySelector("textarea")!, "Account B edit"));
|
||||
expect(localStorage.getItem("paperclip:inline-draft:v2:user-2:private-switch")).toContain("Account B edit");
|
||||
expect(localStorage.getItem("paperclip:inline-draft:v2:user-2:private-switch")).not.toContain("Private account A draft");
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
client.clear();
|
||||
localStorage.clear();
|
||||
mockSession.userId = "user-1";
|
||||
}
|
||||
});
|
||||
|
||||
it("does not call onSave when nullable is false/omitted and the field is cleared", () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const root = createRoot(container);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
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 { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||
import { useDurableAutosave } from "../hooks/useDurableAutosave";
|
||||
import { useDraftOwner } from "../hooks/useDraftOwner";
|
||||
import { loadStructuredDraft, saveStructuredDraft } from "../lib/composer-draft";
|
||||
|
||||
type StoredInlineDraft = { version: 1; draft: string; base: string };
|
||||
import { FoldCurtain } from "./FoldCurtain";
|
||||
|
||||
interface InlineEditorProps {
|
||||
@@ -31,6 +35,11 @@ interface InlineEditorProps {
|
||||
defaultEditing?: boolean;
|
||||
/** Notified when the multiline editor swaps between display and edit mode. */
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
/**
|
||||
* Keep unsaved edits in browser storage under this key, so a reload or an
|
||||
* outage does not lose them. A stored edit is restored and saved on mount.
|
||||
*/
|
||||
draftKey?: string;
|
||||
}
|
||||
|
||||
/** Shared padding so display and edit modes occupy the exact same box. */
|
||||
@@ -55,7 +64,15 @@ export function queueContainedBlurCommit(container: HTMLDivElement, onCommit: ()
|
||||
};
|
||||
}
|
||||
|
||||
export function InlineEditor({
|
||||
export function InlineEditor(props: InlineEditorProps) {
|
||||
const draftOwner = useDraftOwner();
|
||||
const [scope, setScope] = useState({ owner: draftOwner, generation: 0 });
|
||||
const generation = scope.generation + (scope.owner !== undefined && scope.owner !== draftOwner ? 1 : 0);
|
||||
if (scope.owner !== draftOwner) setScope({ owner: draftOwner, generation });
|
||||
return <OwnedInlineEditor key={generation} {...props} draftOwner={draftOwner} />;
|
||||
}
|
||||
|
||||
function OwnedInlineEditor({
|
||||
value,
|
||||
onSave,
|
||||
as: Tag = "span",
|
||||
@@ -70,11 +87,14 @@ export function InlineEditor({
|
||||
externalReferences,
|
||||
defaultEditing = false,
|
||||
onEditingChange,
|
||||
}: InlineEditorProps) {
|
||||
draftKey,
|
||||
draftOwner,
|
||||
}: InlineEditorProps & { draftOwner: string | undefined }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [multilineEditing, setMultilineEditing] = useState(multiline && defaultEditing);
|
||||
const [multilineFocused, setMultilineFocused] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const unownedDraft = useRef<StoredInlineDraft | null>(null);
|
||||
const lastPropValueRef = useRef(value);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const markdownRef = useRef<MarkdownEditorRef>(null);
|
||||
@@ -83,12 +103,46 @@ export function InlineEditor({
|
||||
const pendingFocusFrameRef = useRef<number | null>(null);
|
||||
const justEnteredEditRef = useRef(multiline && defaultEditing);
|
||||
const hasBeenFocusedRef = useRef(false);
|
||||
const {
|
||||
state: autosaveState,
|
||||
markDirty,
|
||||
reset,
|
||||
runSave,
|
||||
} = useAutosaveIndicator();
|
||||
const autosave = useDurableAutosave<string>({
|
||||
sourceId: `inline-editor:${useId()}`,
|
||||
targetKey: draftKey ?? undefined,
|
||||
save: async (next) => {
|
||||
await onSave(next);
|
||||
},
|
||||
});
|
||||
const { markDirty, reset, run: runAutosave } = autosave;
|
||||
const autosaveState = autosave.status;
|
||||
const autosaveStateRef = useRef(autosaveState);
|
||||
autosaveStateRef.current = autosaveState;
|
||||
|
||||
// Unsaved edits are stored with the value they were based on, so a restored
|
||||
// edit never silently overwrites a newer server value, and per signed-in
|
||||
// account, so another account in this browser never sees or saves them.
|
||||
const storageKey = draftKey && draftOwner !== undefined ? `paperclip:inline-draft:v2:${draftOwner}:${draftKey}` : null;
|
||||
const clearStoredDraft = useCallback(() => {
|
||||
if (!storageKey) return;
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// Unavailable storage holds nothing to clear.
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
// Any path that lands the edit (blur, autosave, a resend after an outage)
|
||||
// updates `value`; the stored copy is then no longer needed.
|
||||
useEffect(() => {
|
||||
if (!storageKey) return;
|
||||
const stored = loadStructuredDraft<StoredInlineDraft | null>(storageKey, null);
|
||||
if (stored && typeof stored.draft === "string" && stored.draft.trim() === value.trim()) clearStoredDraft();
|
||||
}, [clearStoredDraft, storageKey, value]);
|
||||
|
||||
const changeDraft = useCallback((next: string) => {
|
||||
setDraft(next);
|
||||
if (draftOwner === undefined) unownedDraft.current = { version: 1, draft: next, base: value };
|
||||
if (!storageKey) return;
|
||||
if (next.trim() === value.trim()) clearStoredDraft();
|
||||
else saveStructuredDraft(storageKey, { version: 1, draft: next, base: value } satisfies StoredInlineDraft);
|
||||
}, [clearStoredDraft, draftOwner, storageKey, value]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousValue = lastPropValueRef.current;
|
||||
@@ -101,6 +155,36 @@ export function InlineEditor({
|
||||
});
|
||||
}, [value, multiline, multilineFocused]);
|
||||
|
||||
// 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;
|
||||
// otherwise it waits in the editor for the user.
|
||||
const restoredDraftRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!storageKey || restoredDraftRef.current === storageKey) return;
|
||||
restoredDraftRef.current = storageKey;
|
||||
if (unownedDraft.current) {
|
||||
if (draft.trim() !== value.trim()) saveStructuredDraft(storageKey, { ...unownedDraft.current, draft });
|
||||
else clearStoredDraft();
|
||||
unownedDraft.current = null;
|
||||
return;
|
||||
}
|
||||
const stored = loadStructuredDraft<StoredInlineDraft | null>(storageKey, null);
|
||||
if (!stored || stored.version !== 1 || typeof stored.draft !== "string" || typeof stored.base !== "string") return;
|
||||
if (stored.draft.trim() === value.trim()) {
|
||||
clearStoredDraft();
|
||||
return;
|
||||
}
|
||||
setDraft(stored.draft);
|
||||
if (multiline) {
|
||||
setMultilineEditing(true);
|
||||
onEditingChange?.(true);
|
||||
} else {
|
||||
setEditing(true);
|
||||
}
|
||||
if (stored.base === value) void runAutosave(stored.draft.trim());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
@@ -162,6 +246,7 @@ export function InlineEditor({
|
||||
}
|
||||
if (!multiline || !multilineEditing) return;
|
||||
if (!hasBeenFocusedRef.current) return;
|
||||
// Waiting or failed saves keep the editor open, so the text stays visible.
|
||||
if (autosaveState !== "idle") return;
|
||||
hasBeenFocusedRef.current = false;
|
||||
setMultilineEditing(false);
|
||||
@@ -169,37 +254,39 @@ export function InlineEditor({
|
||||
}, [multiline, multilineEditing, multilineFocused, autosaveState, onEditingChange]);
|
||||
|
||||
|
||||
// A single-line edit that waited for the connection closes once it saves.
|
||||
useEffect(() => {
|
||||
if (multiline || !editing || autosaveState !== "saved") return;
|
||||
if (document.activeElement === inputRef.current) return;
|
||||
setEditing(false);
|
||||
}, [autosaveState, editing, multiline]);
|
||||
|
||||
/** Save the draft if it changed. Never rejects; a failed save keeps the text. */
|
||||
const commit = useCallback(async (nextValue = draft) => {
|
||||
const valueToSave = nextValue.trim();
|
||||
const valueChanged = valueToSave !== value;
|
||||
const shouldSave = nullable
|
||||
? valueChanged
|
||||
: Boolean(valueToSave && valueChanged);
|
||||
if (shouldSave) {
|
||||
await Promise.resolve(onSave(valueToSave));
|
||||
} else {
|
||||
if (!shouldSave) {
|
||||
setDraft(value);
|
||||
if (draftKey) clearStoredDraft();
|
||||
if (!multiline) setEditing(false);
|
||||
return;
|
||||
}
|
||||
if (!multiline) {
|
||||
setEditing(false);
|
||||
}
|
||||
}, [draft, multiline, nullable, onSave, value]);
|
||||
const result = await runAutosave(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]);
|
||||
|
||||
/** Multiline blur/submit: show autosave indicator when persisting */
|
||||
const finalizeMultilineBlurOrSubmit = useCallback(() => {
|
||||
const trimmed = draft.trim();
|
||||
if (trimmed === value) {
|
||||
if (trimmed === value || (!trimmed && !nullable)) {
|
||||
reset();
|
||||
void commit();
|
||||
return;
|
||||
}
|
||||
if (!trimmed && !nullable) {
|
||||
reset();
|
||||
void commit();
|
||||
return;
|
||||
}
|
||||
void runSave(() => commit());
|
||||
}, [commit, draft, nullable, reset, runSave, value]);
|
||||
void commit();
|
||||
}, [commit, draft, nullable, reset, value]);
|
||||
|
||||
const cancelPendingBlurCommit = useCallback(() => {
|
||||
if (blurCommitFrameRef.current === null) return;
|
||||
@@ -230,6 +317,7 @@ export function InlineEditor({
|
||||
}
|
||||
reset();
|
||||
setDraft(value);
|
||||
if (draftKey) clearStoredDraft();
|
||||
if (multiline) {
|
||||
setMultilineFocused(false);
|
||||
setMultilineEditing(false);
|
||||
@@ -250,7 +338,7 @@ export function InlineEditor({
|
||||
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 (autosaveState !== "saved") {
|
||||
if (autosaveStateRef.current !== "saved") {
|
||||
reset();
|
||||
}
|
||||
return;
|
||||
@@ -260,7 +348,7 @@ export function InlineEditor({
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
autosaveDebounceRef.current = setTimeout(() => {
|
||||
void runSave(() => commit(trimmed));
|
||||
void commit(trimmed);
|
||||
}, AUTOSAVE_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
@@ -268,7 +356,7 @@ export function InlineEditor({
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [autosaveState, commit, draft, markDirty, multiline, multilineFocused, nullable, reset, runSave, value]);
|
||||
}, [commit, draft, markDirty, multiline, multilineFocused, nullable, reset, value]);
|
||||
|
||||
if (multiline) {
|
||||
const previewValue = autosaveState === "saved" || autosaveState === "idle" ? draft : value;
|
||||
@@ -353,7 +441,7 @@ export function InlineEditor({
|
||||
<MarkdownEditor
|
||||
ref={markdownRef}
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onChange={changeDraft}
|
||||
placeholder={placeholder}
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
@@ -372,15 +460,11 @@ export function InlineEditor({
|
||||
autosaveState === "error" ? "text-destructive" : "text-muted-foreground",
|
||||
autosaveState === "idle" ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
role={autosaveState === "error" ? "alert" : "status"}
|
||||
>
|
||||
{autosaveState === "saving"
|
||||
? "Autosaving..."
|
||||
: autosaveState === "saved"
|
||||
? "Saved"
|
||||
: autosaveState === "error"
|
||||
? "Could not save"
|
||||
: "Idle"}
|
||||
{autosave.label ?? "Idle"}
|
||||
</span>
|
||||
{autosaveState === "stalled" ? <button type="button" className="ml-2 text-xs text-foreground underline" onClick={autosave.resendNow}>Retry saving</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -394,7 +478,7 @@ export function InlineEditor({
|
||||
value={draft}
|
||||
rows={1}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
changeDraft(e.target.value);
|
||||
autoSize(e.target);
|
||||
}}
|
||||
onBlur={() => {
|
||||
@@ -415,6 +499,7 @@ export function InlineEditor({
|
||||
const DisplayTag = value && multiline ? "div" : Tag;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DisplayTag
|
||||
className={cn(
|
||||
"cursor-pointer rounded hover:bg-accent/50 transition-colors overflow-hidden",
|
||||
@@ -426,5 +511,7 @@ export function InlineEditor({
|
||||
>
|
||||
{value || placeholder}
|
||||
</DisplayTag>
|
||||
{autosaveState === "stalled" ? <button type="button" className="text-xs text-foreground underline" onClick={autosave.resendNow}>Retry saving</button> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,13 @@ import { flushSync } from "react-dom";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { CommentSubmissionUnknownError } from "../lib/comment-submit-result";
|
||||
import { loadDraft, preserveDraftInTab, settleDraftSubmission } from "../lib/composer-draft";
|
||||
import { loadDraft, saveDraft, saveDraftSubmission, loadDraftSubmission, preserveDraftInTab, settleDraftSubmission } from "../lib/composer-draft";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useAccountBoundMutation } from "../hooks/useAccountBoundMutation";
|
||||
import {
|
||||
IssueAssigneePausedNotice,
|
||||
IssueChatThread,
|
||||
@@ -80,6 +83,12 @@ const {
|
||||
shouldPreserveComposerViewportMock: vi.fn(),
|
||||
}));
|
||||
|
||||
// The session lookup never answers on its own; tests set the account directly.
|
||||
vi.mock("../api/auth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../api/auth")>();
|
||||
return { ...actual, authApi: { ...actual.authApi, getSession: () => new Promise(() => {}) } };
|
||||
});
|
||||
|
||||
vi.mock("@assistant-ui/react", () => ({
|
||||
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
@@ -2900,13 +2909,156 @@ describe("IssueChatThread", () => {
|
||||
)?.value).toBe(nextDraft);
|
||||
expect(localStorage.getItem(key)).toBe(nextDraft);
|
||||
expect(localStorage.getItem(`${key}:submission:v1`)).toBeNull();
|
||||
expect(container.textContent).not.toContain("We couldn’t confirm");
|
||||
expect(container.textContent).not.toContain("Sending when reconnected…");
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
}
|
||||
expect(localStorage.getItem(key)).toBe(nextDraft);
|
||||
});
|
||||
|
||||
it("keeps the original key after account cancellation of an accepted initial send", async () => {
|
||||
const key = "accepted-thread-account-roundtrip";
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
let accepted!: () => void;
|
||||
const post = vi.fn<(body: string, requestId?: string) => Promise<void>>()
|
||||
.mockImplementationOnce(() => new Promise<void>(resolve => { accepted = resolve; })).mockResolvedValue(undefined);
|
||||
function OwnedThread() {
|
||||
const mutation = useAccountBoundMutation({ mutationFn: ({ body, requestId }: { body: string; requestId?: string }) => post(body, requestId) });
|
||||
return <IssueChatThread comments={[]} currentUserId="user-1" linkedRuns={[]} timelineEvents={[]} liveRuns={[]}
|
||||
draftKey={key} enableLiveTranscriptPolling={false}
|
||||
onAdd={(body, _reopen, _reassign, _files, requestId) => mutation.mutateAsync({ body, requestId })} />;
|
||||
}
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter><OwnedThread /></MemoryRouter></QueryClientProvider>));
|
||||
const editor = () => container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Issue chat editor"]')!;
|
||||
act(() => {
|
||||
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!.call(editor(), "Accepted before the account changed");
|
||||
editor().dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
const button = (label: string) => Array.from(container.querySelectorAll("button")).find(item => item.textContent === label)!;
|
||||
await act(async () => button("Send").click());
|
||||
const requestId = post.mock.calls[0][1]!;
|
||||
act(() => {
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
});
|
||||
await act(async () => accepted());
|
||||
expect(loadDraftSubmission(key)?.attemptId).toBe(requestId);
|
||||
expect(editor().value).toBe("Accepted before the account changed");
|
||||
await act(async () => button("Resend now").click());
|
||||
expect(post).toHaveBeenCalledTimes(2);
|
||||
expect(post.mock.calls[1][1]).toBe(requestId);
|
||||
expect(loadDraftSubmission(key)).toBeNull();
|
||||
} finally { act(() => root.unmount()); client.clear(); }
|
||||
});
|
||||
|
||||
it.each([false, true])("does not retry a stale account render (returns to original=%s)", async (returns) => {
|
||||
const key = "stale-thread-account";
|
||||
const attemptId = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
|
||||
saveDraft(key, "Account A text");
|
||||
saveDraftSubmission(key, { attemptId, reviewed: false, request: { body: "Account A text" }, requestOwner: "user-1" });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter>
|
||||
<IssueChatThread comments={[]} currentUserId="user-1" linkedRuns={[]} timelineEvents={[]} liveRuns={[]}
|
||||
onAdd={onAdd} draftKey={key} enableLiveTranscriptPolling={false} />
|
||||
</MemoryRouter></QueryClientProvider>));
|
||||
const resend = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Resend now")!;
|
||||
await act(async () => {
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } });
|
||||
if (returns) client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
resend.click();
|
||||
});
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
expect(loadDraftSubmission(key)?.attemptId).toBe(attemptId);
|
||||
} finally { act(() => root.unmount()); client.clear(); }
|
||||
});
|
||||
|
||||
it("resends an unconfirmed comment with the same request ID and keeps the next draft", async () => {
|
||||
const key = "resend-unconfirmed-comment";
|
||||
let rejectSend!: (error: Error) => void;
|
||||
const onAdd = vi.fn()
|
||||
.mockReturnValueOnce(new Promise<void>((_, reject) => { rejectSend = reject; }))
|
||||
.mockResolvedValue(undefined);
|
||||
const root = createRoot(container);
|
||||
const editor = () => container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Issue chat editor"]')!;
|
||||
const type = (value: string) => act(() => {
|
||||
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!.call(editor(), value);
|
||||
editor().dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
const button = (label: string) => Array.from(container.querySelectorAll("button")).find(item => item.textContent === label) as HTMLButtonElement;
|
||||
try {
|
||||
await act(async () => root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread comments={[]} currentUserId="user-1" linkedRuns={[]} timelineEvents={[]} liveRuns={[]}
|
||||
onAdd={onAdd} draftKey={key} enableLiveTranscriptPolling={false} />
|
||||
</MemoryRouter>,
|
||||
));
|
||||
type("Typed during the outage");
|
||||
await act(async () => button("Send").click());
|
||||
type("Next thought");
|
||||
await act(async () => rejectSend(new CommentSubmissionUnknownError()));
|
||||
expect(container.textContent).toContain("Sending when reconnected…");
|
||||
expect(JSON.parse(localStorage.getItem(`${key}:submission:v1`)!).request.body).toBe("Typed during the outage");
|
||||
await act(async () => button("Resend now").click());
|
||||
expect(onAdd).toHaveBeenCalledTimes(2);
|
||||
expect(onAdd.mock.calls[1]![0]).toBe("Typed during the outage");
|
||||
expect(onAdd.mock.calls[1]![4]).toBe(onAdd.mock.calls[0]![4]);
|
||||
expect(container.textContent).not.toContain("Sending when reconnected…");
|
||||
expect(editor().value).toBe("Next thought");
|
||||
expect(localStorage.getItem(`${key}:submission:v1`)).toBeNull();
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
}
|
||||
});
|
||||
|
||||
it("resends a comment sent before the session loaded once the account is known", async () => {
|
||||
const key = "resend-before-session";
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const onAdd = vi.fn()
|
||||
.mockRejectedValueOnce(new CommentSubmissionUnknownError())
|
||||
.mockResolvedValue(undefined);
|
||||
const root = createRoot(container);
|
||||
const editor = () => container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Issue chat editor"]')!;
|
||||
const button = (label: string) => Array.from(container.querySelectorAll("button")).find(item => item.textContent === label) as HTMLButtonElement | undefined;
|
||||
try {
|
||||
await act(async () => root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter>
|
||||
<IssueChatThread comments={[]} currentUserId="user-1" linkedRuns={[]} timelineEvents={[]} liveRuns={[]}
|
||||
onAdd={onAdd} draftKey={key} enableLiveTranscriptPolling={false} />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
));
|
||||
act(() => {
|
||||
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!.call(editor(), "Sent before the session");
|
||||
editor().dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => button("Send")!.click());
|
||||
// Until the account is known the send cannot be replayed.
|
||||
expect(button("Resend now")).toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem(`${key}:submission:v1`)!).requestOwner).toBe("user-1");
|
||||
expect(container.textContent).toContain("Sending when reconnected…");
|
||||
await act(async () => button("Resend now")!.click());
|
||||
expect(onAdd).toHaveBeenCalledTimes(2);
|
||||
expect(onAdd.mock.calls[1]![4]).toBe(onAdd.mock.calls[0]![4]);
|
||||
expect(container.textContent).not.toContain("Sending when reconnected…");
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
client.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["submission write", "all storage"])(
|
||||
"unlocks a confirmed in-memory submission after failed %s",
|
||||
async (failure) => {
|
||||
@@ -2944,10 +3096,10 @@ describe("IssueChatThread", () => {
|
||||
const attemptId = onAdd.mock.calls[0]![4] as string;
|
||||
type("The full next draft");
|
||||
await act(async () => rejectSend(new CommentSubmissionUnknownError()));
|
||||
expect(container.textContent).toContain("We couldn’t confirm");
|
||||
expect(container.textContent).toContain("Sending when reconnected…");
|
||||
await act(async () => root.render(element(attemptId)));
|
||||
expect(editor().value).toBe("The full next draft");
|
||||
expect(container.textContent).not.toContain("We couldn’t confirm");
|
||||
expect(container.textContent).not.toContain("Sending when reconnected…");
|
||||
expect(send().disabled).toBe(false);
|
||||
await act(async () => send().click());
|
||||
expect(onAdd.mock.calls[1]![0]).toBe("The full next draft");
|
||||
@@ -3000,7 +3152,7 @@ describe("IssueChatThread", () => {
|
||||
await act(async () => root.render(element(true)));
|
||||
const editor = () => container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Issue chat editor"]')!;
|
||||
expect(editor().value).toBe(localText);
|
||||
expect(container.textContent).not.toContain("We couldn’t confirm");
|
||||
expect(container.textContent).not.toContain("Sending when reconnected…");
|
||||
if (scenario === "unavailable recovery storage") expect(container.textContent).toContain("copy your text before leaving");
|
||||
if (scenario.includes("with new attachments")) expect(container.textContent).toContain("another-tab.txt");
|
||||
if (scenario === "foreign pending receipt") {
|
||||
@@ -3455,7 +3607,7 @@ describe("IssueChatThread", () => {
|
||||
expect(localStorage.getItem(`${key}:submission:v1`)).toBeNull();
|
||||
expect(localStorage.getItem(`${key}:attachments:v1`)).toContain(attachmentId);
|
||||
expect(container.textContent).toContain("next-draft.txt");
|
||||
expect(container.textContent).not.toContain("We couldn’t confirm");
|
||||
expect(container.textContent).not.toContain("Sending when reconnected…");
|
||||
await act(async () => root.unmount());
|
||||
await act(async () => resolveSend());
|
||||
expect(localStorage.getItem(key)).toBe("Newer unsent draft");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCurrentAccountGuard } from "../hooks/useAccountBoundMutation";
|
||||
import { resolveIssueChatHumanAuthor } from "@/lib/issue-chat-human-author";
|
||||
import { useCurrentWorkspaceRetry, useWorkspaceBaseRefRecovery, useWorkspaceRetry } from "./WorkspaceBaseRefRecovery";
|
||||
import { WorkspaceBaseRefRecoveryNotice } from "./WorkspaceBaseRefRecoveryNotice";
|
||||
@@ -76,12 +77,20 @@ import {
|
||||
loadDraftAttachments,
|
||||
saveDraftAttachments,
|
||||
loadDraftSubmission,
|
||||
replayableRequest,
|
||||
saveDraftSubmission,
|
||||
clearDraftSubmission,
|
||||
settleDraftSubmission,
|
||||
type ComposerDraftSubmission,
|
||||
type ComposerSendRequest,
|
||||
} from "../lib/composer-draft";
|
||||
import { CommentSubmissionUnknownError } from "../lib/comment-submit-result";
|
||||
import { classifySendFailure, type SendOutcome } from "../lib/pending-send";
|
||||
import { useAutoResend } from "../hooks/useDurableSubmit";
|
||||
import { useDraftOwner } from "../hooks/useDraftOwner";
|
||||
import { useRequestOwnerBinding } from "../hooks/useRequestOwnerBinding";
|
||||
import { PendingSendNotice } from "./PendingSendNotice";
|
||||
import { describeError } from "../api/errors";
|
||||
import {
|
||||
buildIssueChatMessages,
|
||||
formatDurationWords,
|
||||
@@ -236,6 +245,7 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
RotateCw,
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Brain,
|
||||
@@ -515,7 +525,6 @@ export interface IssueChatComposerHandle {
|
||||
interface IssueChatComposerProps {
|
||||
onSend: IssueChatThreadProps["onAdd"];
|
||||
confirmedSubmissionIds: ReadonlySet<string>;
|
||||
onReviewConversation?: () => Promise<void>;
|
||||
onStop?: () => Promise<void>;
|
||||
stopPending?: boolean;
|
||||
stopScope?: "leaf" | "subtree";
|
||||
@@ -627,7 +636,6 @@ interface IssueChatThreadProps {
|
||||
clientRequestId?: string,
|
||||
runSettings?: ComposerRunSettings,
|
||||
) => Promise<void>;
|
||||
onReviewConversation?: () => Promise<void>;
|
||||
onCancelRun?: () => Promise<void>;
|
||||
stopPending?: boolean;
|
||||
stopScope?: "leaf" | "subtree";
|
||||
@@ -990,6 +998,11 @@ type ComposerAttachmentItem = {
|
||||
inline: boolean;
|
||||
contentPath?: string;
|
||||
error?: string;
|
||||
/**
|
||||
* The picked file, kept after a failed upload so the chip can offer Retry.
|
||||
* Uploads are not deduped by the server, so they never resend on their own.
|
||||
*/
|
||||
file?: File;
|
||||
};
|
||||
|
||||
function hasFilePayload(evt: ReactDragEvent<HTMLDivElement>) {
|
||||
@@ -4651,7 +4664,6 @@ const IssueChatComposer = forwardRef<
|
||||
{
|
||||
onSend,
|
||||
confirmedSubmissionIds,
|
||||
onReviewConversation,
|
||||
onStop,
|
||||
stopPending,
|
||||
stopScope = "leaf",
|
||||
@@ -4694,11 +4706,13 @@ const IssueChatComposer = forwardRef<
|
||||
// the stored draft. The effect below handles subsequent task-key changes.
|
||||
const [body, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : ""));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [reviewError, setReviewError] = useState(false);
|
||||
const [uncertainSubmission, setUncertainSubmission] =
|
||||
useState<ComposerDraftSubmission | null>(() =>
|
||||
draftKey ? loadDraftSubmission(draftKey) : null,
|
||||
);
|
||||
// Attempts this composer resent and saw confirmed; they settle exactly like
|
||||
// a receipt that arrives through the thread.
|
||||
const [resentSubmissionIds, setResentSubmissionIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const mountedTaskKey = useRef(draftKey);
|
||||
useEffect(() => {
|
||||
mountedTaskKey.current = draftKey;
|
||||
@@ -4710,12 +4724,20 @@ const IssueChatComposer = forwardRef<
|
||||
const bodyRef = useRef(body);
|
||||
bodyRef.current = body;
|
||||
const reconciledSubmissionRef = useRef<{ draftKey: string | undefined; attemptId: string } | null>(null);
|
||||
// Only the account that made a request may replay it.
|
||||
const draftOwner = useDraftOwner();
|
||||
const isCurrentAccount = useCurrentAccountGuard();
|
||||
const pendingDraftRef = useRef<{
|
||||
draftKey: string;
|
||||
attemptId: string;
|
||||
submittedBody: string;
|
||||
submittedAttachmentIds: string[];
|
||||
request: ComposerSendRequest;
|
||||
requestOwner?: string;
|
||||
} | null>(null);
|
||||
const markRequestUnbound = useRequestOwnerBinding({
|
||||
draftKey, owner: draftOwner, uncertainSubmission, setUncertainSubmission, pendingDraftRef,
|
||||
});
|
||||
function changeBody(update: string | ((current: string) => string)) {
|
||||
const value = typeof update === "function" ? update(bodyRef.current) : update;
|
||||
bodyRef.current = value;
|
||||
@@ -4732,6 +4754,8 @@ const IssueChatComposer = forwardRef<
|
||||
attemptId: pending.attemptId, reviewed: false,
|
||||
nextDraftOffset: pending.submittedBody.length + (value ? 2 : 0),
|
||||
submittedAttachmentIds: pending.submittedAttachmentIds,
|
||||
request: pending.request,
|
||||
requestOwner: pending.requestOwner,
|
||||
});
|
||||
}
|
||||
const submittingRef = useRef(submitting);
|
||||
@@ -4847,7 +4871,10 @@ const IssueChatComposer = forwardRef<
|
||||
// A server receipt for this exact request settles a restored submission.
|
||||
// Text equality is not delivery proof: users may intentionally repeat text.
|
||||
useEffect(() => {
|
||||
if (!uncertainSubmission || !confirmedSubmissionIds.has(uncertainSubmission.attemptId)) return;
|
||||
if (
|
||||
!uncertainSubmission ||
|
||||
!(confirmedSubmissionIds.has(uncertainSubmission.attemptId) || resentSubmissionIds.has(uncertainSubmission.attemptId))
|
||||
) return;
|
||||
const { attemptId } = uncertainSubmission;
|
||||
const reconciled = reconciledSubmissionRef.current;
|
||||
if (reconciled && reconciled.draftKey === draftKey && reconciled.attemptId === attemptId) return;
|
||||
@@ -4891,7 +4918,48 @@ const IssueChatComposer = forwardRef<
|
||||
setBody(nextDraft);
|
||||
bodyRef.current = nextDraft;
|
||||
setComposerAttachments(nextAttachments);
|
||||
}, [confirmedSubmissionIds, draftKey, sharedDraftKey, uncertainSubmission]);
|
||||
}, [confirmedSubmissionIds, draftKey, resentSubmissionIds, sharedDraftKey, uncertainSubmission]);
|
||||
|
||||
async function resendUncertainSubmission(): Promise<SendOutcome> {
|
||||
if (!isCurrentAccount()) return "pending";
|
||||
const submission = uncertainSubmission;
|
||||
const request = replayableRequest(submission, draftOwner);
|
||||
if (!submission || !request) return "rejected";
|
||||
const taskKey = draftKey;
|
||||
const args = [
|
||||
request.body,
|
||||
request.reopen,
|
||||
request.reassignment,
|
||||
request.attachmentIds,
|
||||
submission.attemptId,
|
||||
] as const;
|
||||
try {
|
||||
if (request.runSettings) await onSend(...args, request.runSettings as unknown as ComposerRunSettings);
|
||||
else await onSend(...args);
|
||||
} catch (error) {
|
||||
if (!isCurrentAccount()) return "pending";
|
||||
const failure = classifySendFailure(error);
|
||||
if (failure === "rejected") {
|
||||
// A definitive answer: the text is already back in the editor.
|
||||
if (taskKey) clearDraftSubmission(taskKey, submission.attemptId);
|
||||
if (mountedTaskKey.current === taskKey) setUncertainSubmission(null);
|
||||
}
|
||||
return failure;
|
||||
}
|
||||
if (mountedTaskKey.current === taskKey) {
|
||||
setResentSubmissionIds((current) => new Set([...current, submission.attemptId]));
|
||||
} else if (taskKey) {
|
||||
settleDraftSubmission(taskKey, submission.attemptId);
|
||||
}
|
||||
return "sent";
|
||||
}
|
||||
|
||||
const resendSourceId = useId();
|
||||
const autoResend = useAutoResend({
|
||||
sourceId: `issue-chat-composer:${resendSourceId}`,
|
||||
active: Boolean(replayableRequest(uncertainSubmission, draftOwner)) && !submitting,
|
||||
resend: resendUncertainSubmission,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -5050,6 +5118,8 @@ const IssueChatComposer = forwardRef<
|
||||
bodyRef.current = "";
|
||||
setBody("");
|
||||
let attemptId: string | null = null;
|
||||
let request: ComposerSendRequest | null = null;
|
||||
const requestOwner = draftOwner;
|
||||
try {
|
||||
if (workModeChanged && onWorkModeChange) {
|
||||
await onWorkModeChange(pendingWorkMode);
|
||||
@@ -5061,10 +5131,20 @@ const IssueChatComposer = forwardRef<
|
||||
return;
|
||||
}
|
||||
attemptId = crypto.randomUUID();
|
||||
if (requestOwner === undefined) markRequestUnbound(attemptId);
|
||||
// The exact request is stored before it is sent, so a lost response or
|
||||
// an outage can resend it with the same ID instead of losing it.
|
||||
request = {
|
||||
body: submittedBody,
|
||||
...(reopen === undefined ? {} : { reopen }),
|
||||
...(reassignment ? { reassignment } : {}),
|
||||
...(attachmentIds.length ? { attachmentIds } : {}),
|
||||
...(runSettings ? { runSettings: runSettings as unknown as Record<string, unknown> } : {}),
|
||||
};
|
||||
if (draftKey) {
|
||||
saveDraft(draftKey, trimmed);
|
||||
saveDraftSubmission(draftKey, { attemptId, reviewed: false });
|
||||
pendingDraftRef.current = { draftKey, attemptId, submittedBody: trimmed, submittedAttachmentIds: attachmentIds };
|
||||
saveDraftSubmission(draftKey, { attemptId, reviewed: false, request, requestOwner });
|
||||
pendingDraftRef.current = { draftKey, attemptId, submittedBody: trimmed, submittedAttachmentIds: attachmentIds, request, requestOwner };
|
||||
changeBody(bodyRef.current);
|
||||
}
|
||||
// assistant-ui thread.append is fire-and-forget. Await the actual Board
|
||||
@@ -5090,11 +5170,15 @@ const IssueChatComposer = forwardRef<
|
||||
} catch (error) {
|
||||
if (mountedTaskKey.current !== draftKey) return;
|
||||
const nextDraft = bodyRef.current;
|
||||
if (attemptId && error instanceof CommentSubmissionUnknownError) {
|
||||
// Not delivered, or not known to be: keep it and resend with the same
|
||||
// ID when the connection allows. The server dedupes by that ID.
|
||||
if (attemptId && request && classifySendFailure(error) === "pending") {
|
||||
const uncertain = {
|
||||
attemptId, reviewed: false,
|
||||
nextDraftOffset: trimmed.length + (nextDraft ? 2 : 0),
|
||||
submittedAttachmentIds: attachmentIds,
|
||||
request,
|
||||
requestOwner,
|
||||
};
|
||||
setUncertainSubmission(uncertain);
|
||||
if (draftKey && loadDraftSubmission(draftKey)?.attemptId === attemptId)
|
||||
@@ -5197,7 +5281,8 @@ const IssueChatComposer = forwardRef<
|
||||
? {
|
||||
...item,
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Upload failed",
|
||||
error: describeError(err, { action: "upload the file" }).body,
|
||||
file,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
@@ -5205,6 +5290,14 @@ const IssueChatComposer = forwardRef<
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry a failed upload from its kept file; only ever on an explicit click. */
|
||||
function retryUpload(attachment: ComposerAttachmentItem) {
|
||||
const file = attachment.file;
|
||||
if (!file) return;
|
||||
setComposerAttachments((current) => current.filter((item) => item.id !== attachment.id));
|
||||
void attachFile(file);
|
||||
}
|
||||
|
||||
async function handleAttachFile(evt: ChangeEvent<HTMLInputElement>) {
|
||||
const file = evt.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -5422,67 +5515,19 @@ const IssueChatComposer = forwardRef<
|
||||
</p>
|
||||
) : null}
|
||||
{uncertainSubmission ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-3 space-y-2 rounded-md border border-border bg-muted p-3 text-sm"
|
||||
>
|
||||
<p>
|
||||
We couldn’t confirm whether this comment was saved. It may already
|
||||
be in the conversation. Review it before starting another draft.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
setReviewError(false);
|
||||
try {
|
||||
if (!onReviewConversation)
|
||||
throw new Error("Review unavailable");
|
||||
await onReviewConversation();
|
||||
if (mountedTaskKey.current !== draftKey) return;
|
||||
const reviewed = { ...uncertainSubmission, reviewed: true };
|
||||
setUncertainSubmission(reviewed);
|
||||
if (
|
||||
draftKey &&
|
||||
loadDraftSubmission(draftKey)?.attemptId ===
|
||||
reviewed.attemptId
|
||||
)
|
||||
saveDraftSubmission(draftKey, reviewed);
|
||||
} catch {
|
||||
setReviewError(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Review conversation
|
||||
</Button>
|
||||
{reviewError ? (
|
||||
<p>Couldn’t refresh the conversation. Try reviewing it again.</p>
|
||||
) : null}
|
||||
{uncertainSubmission.reviewed ? (
|
||||
<>
|
||||
<p>
|
||||
Discarding this draft does not remove any saved comment or
|
||||
uploaded file.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (draftKey)
|
||||
clearDraft(draftKey, uncertainSubmission.attemptId);
|
||||
bodyRef.current = "";
|
||||
setBody("");
|
||||
setComposerAttachments([]);
|
||||
setUncertainSubmission(null);
|
||||
}}
|
||||
>
|
||||
Discard draft and start new
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<PendingSendNotice
|
||||
className="mb-3"
|
||||
noun="comment"
|
||||
persisted={Boolean(draftKey && loadDraftSubmission(draftKey)?.attemptId === uncertainSubmission.attemptId && replayableRequest(loadDraftSubmission(draftKey), draftOwner))}
|
||||
resending={autoResend.resending}
|
||||
stalled={autoResend.stalled}
|
||||
onResendNow={replayableRequest(uncertainSubmission, draftOwner) ? autoResend.resendNow : undefined}
|
||||
onCancel={() => {
|
||||
// Stop resending; the text stays in the editor to edit or send again.
|
||||
if (draftKey) clearDraftSubmission(draftKey, uncertainSubmission.attemptId);
|
||||
setUncertainSubmission(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<MarkdownEditor
|
||||
ref={editorRef}
|
||||
@@ -5568,6 +5613,18 @@ const IssueChatComposer = forwardRef<
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{statusLabel}
|
||||
</span>
|
||||
{attachment.status === "error" && attachment.file ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Retry ${attachment.name}`}
|
||||
disabled={!!uncertainSubmission}
|
||||
onClick={() => retryUpload(attachment)}
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" aria-hidden />
|
||||
</Button>
|
||||
) : null}
|
||||
{!attachment.inline || attachment.status !== "attached" ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -5799,7 +5856,6 @@ export function IssueChatThread({
|
||||
userProfileMap,
|
||||
onVote,
|
||||
onAdd,
|
||||
onReviewConversation,
|
||||
onCancelRun,
|
||||
stopPending,
|
||||
stopScope,
|
||||
@@ -6798,7 +6854,6 @@ export function IssueChatThread({
|
||||
<IssueChatComposer
|
||||
ref={composerRef}
|
||||
onSend={sendComposerComment}
|
||||
onReviewConversation={onReviewConversation}
|
||||
onImageUpload={imageUploadHandler}
|
||||
onAttachImage={onAttachImage}
|
||||
draftKey={draftKey}
|
||||
|
||||
@@ -24,6 +24,8 @@ const mockIssuesApi = vi.hoisted(() => ({
|
||||
|
||||
const markdownEditorMockState = vi.hoisted(() => ({
|
||||
emitMountEmptyChange: false,
|
||||
/** Typed into the editor on mount, for forms the test cannot type into. */
|
||||
emitMountValue: null as string | null,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -48,13 +50,14 @@ vi.mock("../api/issues", () => ({
|
||||
issuesApi: mockIssuesApi,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useAutosaveIndicator", () => ({
|
||||
useAutosaveIndicator: () => ({
|
||||
state: "idle",
|
||||
markDirty: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
runSave: async (save: () => Promise<unknown>) => save(),
|
||||
}),
|
||||
const mockSession = vi.hoisted(() => ({ userId: "user-1", unreachable: false }));
|
||||
vi.mock("../api/auth", () => ({
|
||||
authApi: {
|
||||
getSession: async () => {
|
||||
if (mockSession.unreachable) throw new Error("Service unavailable");
|
||||
return { user: { id: mockSession.userId } };
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
@@ -92,6 +95,10 @@ vi.mock("./MarkdownEditor", async () => {
|
||||
contentClassName?: string;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
if (markdownEditorMockState.emitMountValue !== null) {
|
||||
onChange?.(markdownEditorMockState.emitMountValue);
|
||||
return;
|
||||
}
|
||||
if (!markdownEditorMockState.emitMountEmptyChange) return;
|
||||
onChange?.("");
|
||||
}, []);
|
||||
@@ -293,6 +300,8 @@ describe("IssueDocumentsSection", () => {
|
||||
window.localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
markdownEditorMockState.emitMountEmptyChange = false;
|
||||
markdownEditorMockState.emitMountValue = null;
|
||||
mockSession.unreachable = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -872,6 +881,220 @@ describe("IssueDocumentsSection", () => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("does not restore another account's unsaved edit", async () => {
|
||||
const document = createIssueDocument({ body: "Server body" });
|
||||
const issue = createIssue();
|
||||
window.localStorage.setItem(
|
||||
`paperclip:document-draft:v2:user-2:${issue.id}:plan`,
|
||||
JSON.stringify({ key: "plan", title: "Plan", body: "Someone else's draft", baseRevisionId: "revision-4", isNew: false }),
|
||||
);
|
||||
mockIssuesApi.listDocuments.mockResolvedValue([document]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
expect(container.textContent).toContain("Server body");
|
||||
expect(container.textContent).not.toContain("Someone else's draft");
|
||||
expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
queryClient.clear();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("drops a mounted editor's private draft when the signed-in account changes", async () => {
|
||||
const issue = createIssue();
|
||||
const document = createIssueDocument({ body: "Server plan" });
|
||||
mockIssuesApi.listDocuments.mockResolvedValue([document]);
|
||||
window.localStorage.setItem(`paperclip:document-draft:v2:user-1:${issue.id}:plan`, JSON.stringify({
|
||||
key: "plan", title: document.title ?? "", body: "Private unpublished text", baseRevisionId: "revision-4", isNew: false,
|
||||
}));
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } });
|
||||
queryClient.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
const root = createRoot(container);
|
||||
await act(async () => { root.render(<QueryClientProvider client={queryClient}><IssueDocumentsSection issue={issue} canDeleteDocuments={false} /></QueryClientProvider>); });
|
||||
await flush(); await flush();
|
||||
expect(container.textContent).toContain("Private unpublished text");
|
||||
await act(async () => { queryClient.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } }); });
|
||||
await flush();
|
||||
expect(window.localStorage.getItem(`paperclip:document-draft:v2:user-2:${issue.id}:plan`)).toBeNull();
|
||||
expect(container.textContent).not.toContain("Private unpublished text");
|
||||
act(() => root.unmount()); queryClient.clear();
|
||||
});
|
||||
it("restores task B without copying task A's mounted document draft", async () => {
|
||||
const a = createIssue();
|
||||
const b = { ...a, id: "task-b", identifier: "DOC-2" };
|
||||
const document = createIssueDocument({ body: "Server plan" });
|
||||
mockIssuesApi.listDocuments.mockResolvedValue([document]);
|
||||
const key = (id: string) => `paperclip:document-draft:v2:user-1:${id}:plan`;
|
||||
const stored = (body: string) => JSON.stringify({ key: "plan", title: "Plan", body, baseRevisionId: "revision-4", isNew: false });
|
||||
localStorage.setItem(key(a.id), stored("Task A private edit"));
|
||||
const originalB = stored("Task B private edit");
|
||||
localStorage.setItem(key(b.id), originalB);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
client.setQueryData(queryKeys.issues.documents(a.id), [document]);
|
||||
client.setQueryData(queryKeys.issues.documents(b.id), [document]);
|
||||
const root = createRoot(container);
|
||||
const show = (issue: Issue) => root.render(<QueryClientProvider client={client}><IssueDocumentsSection issue={issue} canDeleteDocuments={false} /></QueryClientProvider>);
|
||||
try {
|
||||
await act(async () => show(a)); await flush();
|
||||
expect(container.textContent).toContain("Task A private edit");
|
||||
await act(async () => show(b)); await flush();
|
||||
expect(container.textContent).not.toContain("Task A private edit");
|
||||
expect(container.textContent).toContain("Task B private edit");
|
||||
expect(localStorage.getItem(key(b.id))).toBe(originalB);
|
||||
} finally { act(() => root.unmount()); client.clear(); localStorage.clear(); }
|
||||
});
|
||||
|
||||
it("keeps the known account's drafts while a session refetch fails", async () => {
|
||||
const document = createIssueDocument({ body: "Server body" });
|
||||
const issue = createIssue();
|
||||
window.localStorage.setItem(
|
||||
`paperclip:document-draft:v2:user-1:${issue.id}:plan`,
|
||||
JSON.stringify({ key: "plan", title: "Plan", body: "Typed before the outage", baseRevisionId: "revision-4", isNew: false }),
|
||||
);
|
||||
mockIssuesApi.listDocuments.mockResolvedValue([document]);
|
||||
mockSession.unreachable = true;
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
// Signed in earlier; the refetch on mount fails but the query keeps its data.
|
||||
queryClient.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }, { updatedAt: 0 });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
expect(queryClient.getQueryState(queryKeys.auth.session)?.status).toBe("error");
|
||||
expect(container.textContent).toContain("Typed before the outage");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
queryClient.clear();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("restores an unsaved edit after reload and treats a 409 with our own text as saved", async () => {
|
||||
const { ApiError } = await import("../api/client");
|
||||
const document = createIssueDocument({ body: "Server body" });
|
||||
const issue = createIssue();
|
||||
// An edit typed before a reload; its save may have landed with a lost response.
|
||||
window.localStorage.setItem(
|
||||
`paperclip:document-draft:v2:user-1:${issue.id}:plan`,
|
||||
JSON.stringify({ key: "plan", title: "Plan", body: "Edited during the outage", baseRevisionId: "revision-4", isNew: false }),
|
||||
);
|
||||
const saved = createIssueDocument({ body: "Edited during the outage", latestRevisionId: "revision-5" });
|
||||
// The refetch after saving returns the server's current copy.
|
||||
mockIssuesApi.listDocuments.mockResolvedValueOnce([document]).mockResolvedValue([saved]);
|
||||
mockIssuesApi.upsertDocument.mockRejectedValue(
|
||||
new ApiError("Document was updated by someone else", 409, { error: "Document was updated by someone else" }),
|
||||
);
|
||||
mockIssuesApi.getDocument.mockResolvedValue(saved);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
expect(container.textContent).toContain("Edited during the outage");
|
||||
|
||||
// Autosave debounce.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(mockIssuesApi.upsertDocument).toHaveBeenCalledWith(issue.id, "plan", expect.objectContaining({
|
||||
body: "Edited during the outage",
|
||||
baseRevisionId: "revision-4",
|
||||
}));
|
||||
expect(container.textContent).not.toContain("Out of date");
|
||||
expect(window.localStorage.getItem(`paperclip:document-draft:v2:user-1:${issue.id}:plan`)).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("treats a 409 on a new document whose saved copy is ours as created", async () => {
|
||||
const { ApiError } = await import("../api/client");
|
||||
const issue = createIssue();
|
||||
// An earlier create landed but its response was lost; the key now exists.
|
||||
const saved = createIssueDocument({ key: "notes", title: null, body: "Notes body", latestRevisionId: "revision-1" });
|
||||
mockIssuesApi.listDocuments.mockResolvedValueOnce([]).mockResolvedValue([saved]);
|
||||
mockIssuesApi.upsertDocument.mockRejectedValue(
|
||||
new ApiError("Document already exists", 409, { error: "Document already exists" }),
|
||||
);
|
||||
mockIssuesApi.getDocument.mockResolvedValue(saved);
|
||||
markdownEditorMockState.emitMountValue = "Notes body";
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
|
||||
const newDocument = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("New document"))!;
|
||||
await act(async () => {
|
||||
newDocument.click();
|
||||
});
|
||||
const keyInput = container.querySelector<HTMLInputElement>('input[placeholder="Document key"]')!;
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(keyInput, "notes");
|
||||
keyInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
const create = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Create document"))!;
|
||||
await act(async () => {
|
||||
create.click();
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(mockIssuesApi.upsertDocument).toHaveBeenCalledWith(issue.id, "notes", expect.objectContaining({
|
||||
body: "Notes body",
|
||||
baseRevisionId: null,
|
||||
}));
|
||||
expect(mockIssuesApi.getDocument).toHaveBeenCalledWith(issue.id, "notes");
|
||||
// The form closes as for any created document; no conflict, no error.
|
||||
expect(container.querySelector('input[placeholder="Document key"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain("Out of date");
|
||||
expect(container.textContent).not.toContain("already exists");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("forwards externalReferences to the rendered document body so URL decoration applies", async () => {
|
||||
const issue = createIssue();
|
||||
const root = createRoot(container);
|
||||
|
||||
@@ -14,11 +14,14 @@ import { isSystemIssueDocumentKey } from "@paperclipai/shared";
|
||||
import { useLocation } from "@/lib/router";
|
||||
import { ApiError } from "../api/client";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||
import { useDurableAutosave } from "../hooks/useDurableAutosave";
|
||||
import { describeError } from "../api/errors";
|
||||
import { loadStructuredDraft, saveStructuredDraft } from "../lib/composer-draft";
|
||||
import { copyTextToClipboard } from "../lib/clipboard";
|
||||
import { deriveDocumentRevisionState } from "../lib/document-revisions";
|
||||
import type { CompanyUserProfile } from "../lib/company-members";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useDraftOwner } from "../hooks/useDraftOwner";
|
||||
import { cn, relativeTime } from "../lib/utils";
|
||||
import { FoldCurtain } from "./FoldCurtain";
|
||||
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "./IssueDocumentAnnotations";
|
||||
@@ -134,6 +137,38 @@ function isLockedDocumentError(error: unknown) {
|
||||
return error instanceof ApiError && error.status === 409 && error.message === "Document is locked";
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsaved document edits live here until the server confirms them. The scope
|
||||
* names the signed-in user as well as the document, so another account in the
|
||||
* same browser never restores (or autosaves) someone else's unpublished text.
|
||||
* A null scope (the session is still loading) stores and restores nothing.
|
||||
*/
|
||||
function documentDraftStorageKey(scope: string, key: string) {
|
||||
return `paperclip:document-draft:v2:${scope}:${key}`;
|
||||
}
|
||||
|
||||
function loadStoredDocumentDraft(scope: string | null, key: string): DraftState | null {
|
||||
if (!scope) return null;
|
||||
const stored = loadStructuredDraft<Partial<DraftState> | null>(documentDraftStorageKey(scope, key), null);
|
||||
if (
|
||||
!stored ||
|
||||
stored.key !== key ||
|
||||
typeof stored.body !== "string" ||
|
||||
typeof stored.title !== "string" ||
|
||||
(stored.baseRevisionId !== null && typeof stored.baseRevisionId !== "string")
|
||||
) return null;
|
||||
return { key, title: stored.title, body: stored.body, baseRevisionId: stored.baseRevisionId ?? null, isNew: false };
|
||||
}
|
||||
|
||||
function clearStoredDocumentDraft(scope: string | null, key: string) {
|
||||
if (!scope) return;
|
||||
try {
|
||||
localStorage.removeItem(documentDraftStorageKey(scope, key));
|
||||
} catch {
|
||||
// Unavailable storage holds nothing to clear.
|
||||
}
|
||||
}
|
||||
|
||||
function downloadDocumentFile(key: string, body: string) {
|
||||
const blob = new Blob([body], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -242,7 +277,7 @@ function makeIssueDocumentSubject(issue: Issue): DocumentSubjectConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export function IssueDocumentsSection({
|
||||
function AccountIssueDocumentsSection({
|
||||
issue,
|
||||
subject,
|
||||
canDeleteDocuments,
|
||||
@@ -308,6 +343,8 @@ export function IssueDocumentsSection({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<DraftState | null>(null);
|
||||
const [documentConflict, setDocumentConflict] = useState<DocumentConflictState | null>(null);
|
||||
const draftOwner = useDraftOwner();
|
||||
const draftScope = draftOwner !== undefined ? `${draftOwner}:${documentSubject.id}` : null;
|
||||
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(documentSubject.id));
|
||||
const [annotationPanelOpenKeys, setAnnotationPanelOpenKeys] = useState<string[]>(
|
||||
() => (defaultAnnotationPanelOpenKeys ?? []),
|
||||
@@ -321,12 +358,17 @@ export function IssueDocumentsSection({
|
||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasScrolledToHashRef = useRef(false);
|
||||
const {
|
||||
state: autosaveState,
|
||||
markDirty,
|
||||
reset,
|
||||
runSave,
|
||||
} = useAutosaveIndicator();
|
||||
// Each save binds its own request and 409 check (see commitDraft).
|
||||
const autosave = useDurableAutosave<DraftState>({
|
||||
sourceId: `issue-documents:${documentSubject.id}`,
|
||||
targetKey: `${draftScope}:${draft?.key ?? ""}`,
|
||||
save: async () => undefined,
|
||||
action: "save the document",
|
||||
});
|
||||
const { markDirty, reset, run: runAutosave } = autosave;
|
||||
const autosaveState = autosave.status;
|
||||
const autosaveStateRef = useRef(autosaveState);
|
||||
autosaveStateRef.current = autosaveState;
|
||||
|
||||
const { data: documents } = useQuery({
|
||||
queryKey: documentSubject.documentsQueryKey,
|
||||
@@ -486,7 +528,12 @@ export function IssueDocumentsSection({
|
||||
const beginEdit = (key: string) => {
|
||||
const doc = sortedDocuments.find((entry) => entry.key === key);
|
||||
if (!doc) return;
|
||||
const conflictedDraft = documentConflict?.key === key ? documentConflict.localDraft : null;
|
||||
const storedDraft = loadStoredDocumentDraft(draftScope, key);
|
||||
const conflictedDraft = documentConflict?.key === key
|
||||
? documentConflict.localDraft
|
||||
: storedDraft && (storedDraft.body !== doc.body || storedDraft.title !== (doc.title ?? ""))
|
||||
? storedDraft
|
||||
: null;
|
||||
setFoldedDocumentKeys((current) => current.filter((entry) => entry !== key));
|
||||
resetAutosaveState();
|
||||
setDocumentConflict((current) => current?.key === key ? current : null);
|
||||
@@ -515,6 +562,7 @@ export function IssueDocumentsSection({
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
if (draft && !draft.isNew) clearStoredDocumentDraft(draftScope, draft.key);
|
||||
resetAutosaveState();
|
||||
setDocumentConflict(null);
|
||||
setDraft(null);
|
||||
@@ -574,16 +622,17 @@ export function IssueDocumentsSection({
|
||||
return true;
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const saved = await upsertDocument.mutateAsync({
|
||||
...currentDraft,
|
||||
key: normalizedKey,
|
||||
title: isPlanKey(normalizedKey) ? "" : normalizedTitle,
|
||||
body: currentDraft.body,
|
||||
baseRevisionId: options?.overrideConflict
|
||||
? activeConflict?.serverDocument.latestRevisionId ?? currentDraft.baseRevisionId
|
||||
: currentDraft.baseRevisionId,
|
||||
});
|
||||
const attempted: DraftState = {
|
||||
...currentDraft,
|
||||
key: normalizedKey,
|
||||
title: isPlanKey(normalizedKey) ? "" : normalizedTitle,
|
||||
body: currentDraft.body,
|
||||
baseRevisionId: options?.overrideConflict
|
||||
? activeConflict?.serverDocument.latestRevisionId ?? currentDraft.baseRevisionId
|
||||
: currentDraft.baseRevisionId,
|
||||
};
|
||||
const applySaved = (saved: IssueDocument) => {
|
||||
clearStoredDocumentDraft(draftScope, normalizedKey);
|
||||
setError(null);
|
||||
setDocumentConflict((current) => current?.key === normalizedKey ? null : current);
|
||||
setDraft((value) => {
|
||||
@@ -592,7 +641,8 @@ export function IssueDocumentsSection({
|
||||
return {
|
||||
key: saved.key,
|
||||
title: saved.title ?? "",
|
||||
body: saved.body,
|
||||
// Keep text typed while this save was in flight.
|
||||
body: value.body === currentDraft.body ? saved.body : value.body,
|
||||
baseRevisionId: saved.latestRevisionId,
|
||||
isNew: false,
|
||||
};
|
||||
@@ -600,16 +650,22 @@ export function IssueDocumentsSection({
|
||||
syncDocumentCaches(saved);
|
||||
invalidateIssueDocuments();
|
||||
};
|
||||
|
||||
try {
|
||||
if (options?.trackAutosave) {
|
||||
setAutosaveDocumentKey(normalizedKey);
|
||||
await runSave(save);
|
||||
} else {
|
||||
await save();
|
||||
}
|
||||
const save = async () => {
|
||||
applySaved(await upsertDocument.mutateAsync(attempted));
|
||||
};
|
||||
// A retry after a lost response finds its own earlier save: the base
|
||||
// revision is stale (or, for a new document, the key now exists), but the
|
||||
// server already holds exactly this text.
|
||||
const matchesServer = async (error: unknown) => {
|
||||
if (isLockedDocumentError(error)) return false;
|
||||
const latest = await documentSubject.getDocument(normalizedKey);
|
||||
if (latest.body !== currentDraft.body) return false;
|
||||
if (!isPlanKey(normalizedKey) && (latest.title ?? "") !== normalizedTitle) return false;
|
||||
applySaved(latest);
|
||||
return true;
|
||||
} catch (err) {
|
||||
};
|
||||
|
||||
const handleSaveFailure = async (err: unknown): Promise<false> => {
|
||||
if (isLockedDocumentError(err)) {
|
||||
setError("Document is locked. Unlock it before editing.");
|
||||
resetAutosaveState();
|
||||
@@ -640,14 +696,27 @@ export function IssueDocumentsSection({
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to save document");
|
||||
setError(describeError(err, { action: "save the document" }).body);
|
||||
return false;
|
||||
}
|
||||
}, [documentConflict, documentSubject, invalidateIssueDocuments, resetAutosaveState, runSave, sortedDocuments, syncDocumentCaches, upsertDocument]);
|
||||
};
|
||||
|
||||
if (options?.trackAutosave) setAutosaveDocumentKey(normalizedKey);
|
||||
const result = await runAutosave(attempted, {
|
||||
save,
|
||||
matchesServer,
|
||||
// A retry after an outage reports a lock or a conflict the same way.
|
||||
onRetryFailed: (err) => void handleSaveFailure(err),
|
||||
});
|
||||
if (result.kind === "saved") return true;
|
||||
// Kept on this device; it saves when the connection allows.
|
||||
if (result.kind === "waiting") return false;
|
||||
return handleSaveFailure(result.error);
|
||||
}, [documentConflict, documentSubject, draftScope, invalidateIssueDocuments, resetAutosaveState, runAutosave, sortedDocuments, syncDocumentCaches, upsertDocument]);
|
||||
|
||||
const reloadDocumentFromServer = useCallback((key: string) => {
|
||||
if (documentConflict?.key !== key) return;
|
||||
const serverDocument = documentConflict.serverDocument;
|
||||
clearStoredDocumentDraft(draftScope, key);
|
||||
setDraft({
|
||||
key: serverDocument.key,
|
||||
title: serverDocument.title ?? "",
|
||||
@@ -658,7 +727,7 @@ export function IssueDocumentsSection({
|
||||
setDocumentConflict(null);
|
||||
resetAutosaveState();
|
||||
setError(null);
|
||||
}, [documentConflict, resetAutosaveState]);
|
||||
}, [documentConflict, draftScope, resetAutosaveState]);
|
||||
|
||||
const overwriteDocumentFromDraft = useCallback(async (key: string) => {
|
||||
if (documentConflict?.key !== key) return;
|
||||
@@ -831,6 +900,39 @@ export function IssueDocumentsSection({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Persist every unsaved edit so a reload or an outage cannot lose it.
|
||||
useEffect(() => {
|
||||
if (!draft || draft.isNew) return;
|
||||
const existing = sortedDocuments.find((doc) => doc.key === draft.key);
|
||||
if (!existing) return;
|
||||
if (existing.body === draft.body && (existing.title ?? "") === draft.title) {
|
||||
clearStoredDocumentDraft(draftScope, draft.key);
|
||||
} else if (draftScope) {
|
||||
saveStructuredDraft(documentDraftStorageKey(draftScope, draft.key), draft);
|
||||
}
|
||||
}, [draftScope, draft, sortedDocuments]);
|
||||
|
||||
// After a reload, reopen the first document with an unsaved edit; the
|
||||
// autosave below then saves it.
|
||||
const restoredStoredDraftRef = useRef(false);
|
||||
useEffect(() => {
|
||||
// Wait for the session: the stored draft belongs to one user.
|
||||
if (restoredStoredDraftRef.current || !documents || !draftScope) return;
|
||||
restoredStoredDraftRef.current = true;
|
||||
if (draft) return;
|
||||
for (const doc of sortedDocuments) {
|
||||
const stored = loadStoredDocumentDraft(draftScope, doc.key);
|
||||
if (!stored) continue;
|
||||
if (stored.body === doc.body && stored.title === (doc.title ?? "")) {
|
||||
clearStoredDocumentDraft(draftScope, doc.key);
|
||||
continue;
|
||||
}
|
||||
beginEdit(doc.key);
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [documents, draftScope]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft || draft.isNew) return;
|
||||
if (documentConflict?.key === draft.key) return;
|
||||
@@ -840,7 +942,7 @@ export function IssueDocumentsSection({
|
||||
existing.body !== draft.body ||
|
||||
(existing.title ?? "") !== draft.title;
|
||||
if (!hasChanges) {
|
||||
if (autosaveState !== "saved") {
|
||||
if (autosaveStateRef.current !== "saved") {
|
||||
resetAutosaveState();
|
||||
}
|
||||
return;
|
||||
@@ -858,7 +960,7 @@ export function IssueDocumentsSection({
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [autosaveState, commitDraft, documentConflict, draft, markDocumentDirty, resetAutosaveState, sortedDocuments]);
|
||||
}, [commitDraft, documentConflict, draft, markDocumentDirty, resetAutosaveState, sortedDocuments]);
|
||||
|
||||
const documentBodyShellClassName = "mt-3";
|
||||
const documentBodyPaddingClassName = "";
|
||||
@@ -1309,7 +1411,7 @@ export function IssueDocumentsSection({
|
||||
bodyMarkdown={displayedBody}
|
||||
draftDirty={Boolean(activeDraft) && (
|
||||
(activeDraft?.body ?? doc.body) !== doc.body
|
||||
|| (autosaveDocumentKey === doc.key && autosaveState === "saving")
|
||||
|| (autosaveDocumentKey === doc.key && (autosaveState === "saving" || autosaveState === "waiting"))
|
||||
)}
|
||||
draftConflicted={Boolean(activeConflict)}
|
||||
historicalPreview={isHistoricalPreview}
|
||||
@@ -1343,16 +1445,11 @@ export function IssueDocumentsSection({
|
||||
? activeConflict
|
||||
? "Out of date"
|
||||
: autosaveDocumentKey === doc.key
|
||||
? autosaveState === "saving"
|
||||
? "Autosaving..."
|
||||
: autosaveState === "saved"
|
||||
? "Saved"
|
||||
: autosaveState === "error"
|
||||
? "Could not save"
|
||||
: ""
|
||||
? autosave.label ?? ""
|
||||
: ""
|
||||
: ""}
|
||||
</span>
|
||||
{activeDraft && autosaveDocumentKey === doc.key && autosaveState === "stalled" ? <Button type="button" size="sm" variant="ghost" onClick={autosave.resendNow}>Retry saving</Button> : null}
|
||||
</div>
|
||||
{canVoteOnDocument && doc.latestRevisionId ? (
|
||||
<OutputFeedbackButtons
|
||||
@@ -1414,3 +1511,14 @@ export function IssueDocumentsSection({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueDocumentsSection(props: Parameters<typeof AccountIssueDocumentsSection>[0]) {
|
||||
const owner = useDraftOwner();
|
||||
const previousOwner = useRef(owner);
|
||||
const generation = useRef(0);
|
||||
if (previousOwner.current !== owner) {
|
||||
if (previousOwner.current !== undefined) generation.current += 1;
|
||||
previousOwner.current = owner;
|
||||
}
|
||||
return <AccountIssueDocumentsSection key={JSON.stringify([generation.current, props.subject?.id ?? props.issue?.id])} {...props} />;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { webcrypto } from "node:crypto";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
@@ -9,7 +10,7 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { trackRecentAssignee, trackRecentAssigneeUser } from "../lib/recent-assignees";
|
||||
import { getLastProjectId, trackRecentProject } from "../lib/recent-projects";
|
||||
import { rememberComposerEffort } from "../lib/recent-composer-effort";
|
||||
import { NewIssueDialog } from "./NewIssueDialog";
|
||||
import { addStagedFilesToIssue, NewIssueDialog } from "./NewIssueDialog";
|
||||
|
||||
const primaryState = vi.hoisted(() => ({ primaryAgentId: null as string | null, loading: false }));
|
||||
vi.mock("./primary-agent/PrimaryAgentPresentation", () => ({ usePrimaryAgentPresentation: () => primaryState }));
|
||||
@@ -550,7 +551,7 @@ describe("NewIssueDialog", () => {
|
||||
primaryState.primaryAgentId = primary;
|
||||
if (recent) trackRecentAssignee(recent, "company-1");
|
||||
if (explicit) dialogState.newIssueDefaults = { assigneeAgentId: explicit };
|
||||
if (draft) localStorage.setItem("paperclip:issue-draft", JSON.stringify({ title: "Saved request", description: "", assigneeValue: `agent:${draft}`, projectId: "" }));
|
||||
if (draft) localStorage.setItem("paperclip:issue-draft:user-1", JSON.stringify({ title: "Saved request", description: "", assigneeValue: `agent:${draft}`, projectId: "" }));
|
||||
const { root } = renderDialog(container);
|
||||
await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-composer-assignee-label"]')?.textContent).toBe(expected));
|
||||
act(() => root.unmount());
|
||||
@@ -747,7 +748,7 @@ describe("NewIssueDialog", () => {
|
||||
});
|
||||
|
||||
it("restores private visibility from a saved draft without a project", async () => {
|
||||
localStorage.setItem("paperclip:issue-draft", JSON.stringify({ title: "Private draft", description: "", status: "todo", priority: "", projectId: "", isPrivate: true }));
|
||||
localStorage.setItem("paperclip:issue-draft:user-1", JSON.stringify({ title: "Private draft", description: "", status: "todo", priority: "", projectId: "", isPrivate: true }));
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
const toggle = container.querySelector<HTMLButtonElement>('button[data-testid="composer-private-chip"]')!;
|
||||
@@ -807,7 +808,7 @@ describe("NewIssueDialog", () => {
|
||||
// Saving this form must retain only the explicit toggle choice. A later
|
||||
// standalone draft must not acquire a sticky private flag from its parent.
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 850)); });
|
||||
expect(JSON.parse(localStorage.getItem("paperclip:issue-draft")!).isPrivate).toBe(false);
|
||||
expect(JSON.parse(localStorage.getItem("paperclip:issue-draft:user-1")!).isPrivate).toBe(false);
|
||||
const submit = Array.from(container.querySelectorAll("button")).find(button => button.getAttribute("aria-label") === "Create sub-task")!;
|
||||
await act(async () => submit.click());
|
||||
await flush();
|
||||
@@ -1056,7 +1057,7 @@ describe("NewIssueDialog", () => {
|
||||
id: "project-1", name: "Alpha", workspaces: [{ id: "source-default", isPrimary: true }],
|
||||
executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace" },
|
||||
}]);
|
||||
localStorage.setItem("paperclip:issue-draft", JSON.stringify({
|
||||
localStorage.setItem("paperclip:issue-draft:user-1", JSON.stringify({
|
||||
title: "Draft task", description: "", status: "todo", priority: "medium", assigneeValue: "",
|
||||
reviewerValue: "", approverValue: "", projectId: "project-1",
|
||||
projectWorkspaceId: "stale-source", selectedExecutionWorkspaceId: "stale-workspace", executionWorkspaceMode: "reuse_existing",
|
||||
@@ -1327,7 +1328,7 @@ describe("NewIssueDialog", () => {
|
||||
|
||||
|
||||
it("restores a description-only draft", async () => {
|
||||
localStorage.setItem("paperclip:issue-draft", JSON.stringify({
|
||||
localStorage.setItem("paperclip:issue-draft:user-1", JSON.stringify({
|
||||
title: "", description: "Keep the request without a title", status: "todo", priority: "medium",
|
||||
assigneeValue: "", reviewerValue: "", approverValue: "", projectId: "",
|
||||
assigneeModelOverride: "", assigneeThinkingEffort: "", assigneeChrome: false,
|
||||
@@ -1565,6 +1566,300 @@ describe("NewIssueDialog", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps a create that hit an outage and resends it with the same key", async () => {
|
||||
const { ApiError } = await import("../api/client");
|
||||
dialogState.newIssueDefaults = { title: "Created during a deploy" };
|
||||
mockIssuesApi.create
|
||||
.mockRejectedValueOnce(new ApiError("Unavailable", 503, { error: "tenant_app_unavailable" }))
|
||||
.mockResolvedValue({ id: "issue-created", identifier: "PAP-9", companyId: "company-1", projectId: null });
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
const button = (label: string) => Array.from(container.querySelectorAll("button"))
|
||||
.find((item) => item.getAttribute("aria-label") === label || item.textContent === label)!;
|
||||
|
||||
act(() => button("Create task").click());
|
||||
await waitForAssertion(() => expect(container.textContent).toContain("Sending when reconnected…"));
|
||||
expect(container.textContent).not.toContain("tenant_app_unavailable");
|
||||
expect(localStorage.getItem("paperclip:issue-draft:pending-create:user-1")).toContain(
|
||||
mockIssuesApi.create.mock.calls[0][1].idempotencyKey,
|
||||
);
|
||||
|
||||
// The waiting request is what gets created, so later edits are locked out.
|
||||
expect(container.querySelector<HTMLInputElement>('input[aria-label="Task title"]')?.disabled).toBe(true);
|
||||
|
||||
act(() => button("Resend now").click());
|
||||
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalledTimes(2));
|
||||
expect(mockIssuesApi.create.mock.calls[1][1].idempotencyKey).toBe(mockIssuesApi.create.mock.calls[0][1].idempotencyKey);
|
||||
await waitForAssertion(() => expect(dialogState.closeNewIssue).toHaveBeenCalled());
|
||||
expect(localStorage.getItem("paperclip:issue-draft:pending-create:user-1")).toBeNull();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows why a resent create was rejected", async () => {
|
||||
const { ApiError } = await import("../api/client");
|
||||
dialogState.newIssueDefaults = { title: "Rejected on resend" };
|
||||
mockIssuesApi.create
|
||||
.mockRejectedValueOnce(new ApiError("Unavailable", 503, { error: "tenant_app_unavailable" }))
|
||||
.mockRejectedValueOnce(new ApiError("Project is archived", 422, { error: "Project is archived" }));
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
const button = (label: string) => Array.from(container.querySelectorAll("button"))
|
||||
.find((item) => item.getAttribute("aria-label") === label || item.textContent === label)!;
|
||||
|
||||
act(() => button("Create task").click());
|
||||
await waitForAssertion(() => expect(container.textContent).toContain("Sending when reconnected…"));
|
||||
act(() => button("Resend now").click());
|
||||
await waitForAssertion(() => expect(container.querySelector('[role="alert"]')?.textContent).toContain("Project is archived"));
|
||||
expect(container.textContent).not.toContain("Sending when reconnected…");
|
||||
expect(container.querySelector<HTMLInputElement>('input[aria-label="Task title"]')?.disabled).toBe(false);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("never creates a restored task without the files it was submitted with", async () => {
|
||||
dialogState.newIssueDefaults = { title: "Has files" };
|
||||
localStorage.setItem("paperclip:issue-draft:pending-create:user-1", JSON.stringify({
|
||||
version: 1,
|
||||
key: "paperclip:issue-draft:pending-create:user-1",
|
||||
idempotencyKey: "create-key-123",
|
||||
createdAt: Date.now(),
|
||||
payload: {
|
||||
fingerprint: "{}",
|
||||
data: { companyId: "company-1", title: "Has files" },
|
||||
stagedFileNames: ["spec.pdf"],
|
||||
},
|
||||
}));
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
|
||||
await waitForAssertion(() => expect(container.querySelector('[role="alert"]')?.textContent).toContain("spec.pdf"));
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Attach it again");
|
||||
await act(async () => { container.querySelector<HTMLButtonElement>('[aria-label="Create task"]')!.click(); });
|
||||
await flush();
|
||||
expect(mockIssuesApi.create).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem("paperclip:issue-draft:pending-create:user-1")).toBeNull();
|
||||
// The original key outlives the rejection, so it survives another reload.
|
||||
expect(localStorage.getItem("paperclip:issue-draft:create-awaiting-files:user-1")).toContain("create-key-123");
|
||||
act(() => root.unmount());
|
||||
|
||||
const reloaded = renderDialog(container);
|
||||
await flush();
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("spec.pdf");
|
||||
expect(localStorage.getItem("paperclip:issue-draft:create-awaiting-files:user-1")).toContain("create-key-123");
|
||||
expect(mockIssuesApi.create).not.toHaveBeenCalled();
|
||||
act(() => reloaded.root.unmount());
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps restored create payload immutable when files are reattached (changed title: %s)", async changed => {
|
||||
mockIssuesApi.create.mockResolvedValue({ id: "issue-1", identifier: "PAP-1", companyId: "company-1" });
|
||||
mockIssuesApi.uploadAttachment.mockResolvedValue({});
|
||||
dialogState.newIssueDefaults = { title: "Has files" };
|
||||
const spec = new File(["a"], "spec.pdf", { lastModified: 1 });
|
||||
const notes = new File(["bb"], "notes.pdf", { lastModified: 2 });
|
||||
localStorage.setItem("paperclip:issue-draft:pending-create:user-1", JSON.stringify({
|
||||
version: 1,
|
||||
key: "paperclip:issue-draft:pending-create:user-1",
|
||||
idempotencyKey: "create-key-123",
|
||||
createdAt: Date.now(),
|
||||
payload: {
|
||||
fingerprint: JSON.stringify({ stagedFiles: ["attachment::spec.pdf:1:1", "attachment::notes.pdf:2:2"] }),
|
||||
data: { companyId: "company-1", title: "Has files" },
|
||||
stagedFileNames: ["spec.pdf", "notes.pdf"],
|
||||
},
|
||||
}));
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
await waitForAssertion(() => expect(container.querySelector('[role="alert"]')?.textContent).toContain("spec.pdf"));
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!;
|
||||
Object.defineProperty(input, "files", { configurable: true, value: [notes, spec] });
|
||||
await act(async () => {
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
if (changed) await typeTextareaValue(container.querySelector<HTMLInputElement>('input[aria-label="Task title"]')!, "Changed after restore");
|
||||
const send = container.querySelector<HTMLButtonElement>('[aria-label="Create task"]')!;
|
||||
await act(async () => { send.click(); });
|
||||
await flush();
|
||||
if (changed) {
|
||||
expect(mockIssuesApi.create).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("original fields");
|
||||
} else {
|
||||
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalledTimes(1));
|
||||
expect(mockIssuesApi.create.mock.calls[0][1].idempotencyKey).toBe("create-key-123");
|
||||
expect(mockIssuesApi.create.mock.calls[0][1].title).toBe("Has files");
|
||||
}
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("drops an account's restored create and staged files when the account changes", async () => {
|
||||
dialogState.newIssueDefaults = { title: "Account A saved task" };
|
||||
const file = new File(["a"], "spec.pdf", { lastModified: 1 });
|
||||
const original = JSON.stringify({ version: 1, key: "paperclip:issue-draft:create-awaiting-files:user-1", idempotencyKey: "account-a-create-key", createdAt: Date.now(), payload: { fingerprint: JSON.stringify({ stagedFiles: ["attachment::spec.pdf:1:1"] }), data: { companyId: "company-1", title: "Account A saved task" }, stagedFileNames: ["spec.pdf"] } });
|
||||
localStorage.setItem("paperclip:issue-draft:create-awaiting-files:user-1", original);
|
||||
const { root, queryClient } = renderDialog(container);
|
||||
try {
|
||||
await waitForAssertion(() => expect(container.textContent).toContain("spec.pdf"));
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!;
|
||||
Object.defineProperty(input, "files", { configurable: true, value: [file] });
|
||||
await act(async () => { input.dispatchEvent(new Event("change", { bubbles: true })); }); await flush();
|
||||
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-2" } });
|
||||
queryClient.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } });
|
||||
await waitForAssertion(() => expect(container.querySelector<HTMLInputElement>('input[aria-label="Task title"]')?.value ?? "").toBe(""));
|
||||
expect(container.textContent).not.toContain("spec.pdf");
|
||||
expect(localStorage.getItem("paperclip:issue-draft:create-awaiting-files:user-1")).toBe(original);
|
||||
await typeTextareaValue(container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Describe a task…"]')!, "Account B fresh task");
|
||||
mockIssuesApi.create.mockResolvedValue({ id: "task-b", identifier: "PAP-2", companyId: "company-1" });
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Create task"]')!.click());
|
||||
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalledTimes(1));
|
||||
expect(mockIssuesApi.create.mock.calls[0][1].description).toBe("Account B fresh task");
|
||||
expect(mockIssuesApi.create.mock.calls[0][1].idempotencyKey).not.toBe("account-a-create-key");
|
||||
expect(mockIssuesApi.uploadAttachment).not.toHaveBeenCalled();
|
||||
} finally { act(() => root.unmount()); queryClient.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({
|
||||
title: "", description: "Keep this restored backup", status: "todo", priority: "",
|
||||
assigneeValue: "", reviewerValue: "", approverValue: "", watchdogAgentId: "", watchdogInstructions: "",
|
||||
projectId: "", projectWorkspaceId: "", assigneeModelLane: "primary", assigneeModelOverride: "",
|
||||
assigneeThinkingEffort: "", assigneeChrome: false, executionWorkspaceMode: "shared_workspace",
|
||||
selectedExecutionWorkspaceId: "", workMode: "standard", isPrivate: false, composerSettings: null,
|
||||
}));
|
||||
const { root, queryClient } = renderDialog(container);
|
||||
try {
|
||||
await flush();
|
||||
if (!sessionKnown) queryClient.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
await waitForAssertion(() => expect(container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Describe a task…"]')?.value).toBe("Keep this restored backup"));
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 850)); });
|
||||
expect(JSON.parse(localStorage.getItem("paperclip:issue-draft:user-1") ?? "null")?.description).toBe("Keep this restored backup");
|
||||
dialogState.newIssueOpen = false;
|
||||
act(() => root.render(<QueryClientProvider client={queryClient}><NewIssueDialog /></QueryClientProvider>));
|
||||
await flush();
|
||||
dialogState.newIssueOpen = true;
|
||||
act(() => root.render(<QueryClientProvider client={queryClient}><NewIssueDialog /></QueryClientProvider>));
|
||||
await flush();
|
||||
expect(container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Describe a task…"]')?.value).toBe("Keep this restored backup");
|
||||
} finally { act(() => root.unmount()); queryClient.clear(); }
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps erased task text erased after reopening (session known=%s)", async (sessionKnown) => {
|
||||
if (!sessionKnown) mockAuthApi.getSession.mockReturnValue(new Promise(() => {}));
|
||||
localStorage.setItem("paperclip:issue-draft:user-1", JSON.stringify({ title: "", description: "Older saved text" }));
|
||||
const { root, queryClient } = renderDialog(container);
|
||||
try {
|
||||
await flush();
|
||||
const editor = container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Describe a task…"]')!;
|
||||
await typeTextareaValue(editor, "Erase this task text");
|
||||
await typeTextareaValue(editor, "");
|
||||
if (!sessionKnown) queryClient.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 850)); });
|
||||
await waitForAssertion(() => expect(localStorage.getItem("paperclip:issue-draft:user-1")).toBeNull());
|
||||
dialogState.newIssueOpen = false;
|
||||
act(() => root.render(<QueryClientProvider client={queryClient}><NewIssueDialog /></QueryClientProvider>));
|
||||
await flush();
|
||||
dialogState.newIssueOpen = true;
|
||||
act(() => root.render(<QueryClientProvider client={queryClient}><NewIssueDialog /></QueryClientProvider>));
|
||||
await flush();
|
||||
expect(container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Describe a task…"]')?.value).toBe("");
|
||||
expect(container.textContent).not.toContain("Erase this task text");
|
||||
} finally { act(() => root.unmount()); queryClient.clear(); }
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps task typing through the first session lookup (storage unavailable=%s)", async (unavailable) => {
|
||||
mockAuthApi.getSession.mockReturnValue(new Promise(() => {}));
|
||||
const { root, queryClient } = renderDialog(container);
|
||||
let storage: ReturnType<typeof vi.spyOn> | undefined;
|
||||
try {
|
||||
await flush();
|
||||
await typeTextareaValue(container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Describe a task…"]')!, "Typed before account lookup");
|
||||
if (unavailable) storage = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("Storage unavailable"); });
|
||||
queryClient.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
await flush(); await flush();
|
||||
expect(container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Describe a task…"]')?.value).toBe("Typed before account lookup");
|
||||
if (!unavailable) expect(localStorage.getItem("paperclip:issue-draft:user-1")).toContain("Typed before account lookup");
|
||||
expect(mockIssuesApi.create).not.toHaveBeenCalled();
|
||||
} finally { storage?.mockRestore(); act(() => root.unmount()); queryClient.clear(); }
|
||||
});
|
||||
|
||||
it.each(["create", "upload"] as const)("stops old staged work after an account change during %s", async (during) => {
|
||||
dialogState.newIssueDefaults = { title: "Account A in-flight task" };
|
||||
let finish!: (value: unknown) => void;
|
||||
const waiting = new Promise(resolve => { finish = resolve; });
|
||||
const created = { id: "task-a", identifier: "PAP-1", companyId: "company-1" };
|
||||
mockIssuesApi.create.mockImplementation(() => during === "create" ? waiting : Promise.resolve(created));
|
||||
mockIssuesApi.uploadAttachment.mockImplementation(() => during === "upload" ? waiting : Promise.resolve({}));
|
||||
const { root, queryClient } = renderDialog(container);
|
||||
try {
|
||||
await flush(); await flush();
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!;
|
||||
Object.defineProperty(input, "files", { configurable: true, value: [new File(["a"], "first.pdf"), new File(["b"], "second.pdf")] });
|
||||
await act(async () => { input.dispatchEvent(new Event("change", { bubbles: true })); }); await flush();
|
||||
await act(async () => { container.querySelector<HTMLButtonElement>('[aria-label="Create task"]')!.click(); });
|
||||
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalledTimes(1));
|
||||
if (during === "upload") await waitForAssertion(() => expect(mockIssuesApi.uploadAttachment).toHaveBeenCalledTimes(1));
|
||||
const key = mockIssuesApi.create.mock.calls[0][1].idempotencyKey;
|
||||
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-2" } });
|
||||
queryClient.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } });
|
||||
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);
|
||||
expect(mutation?.state.status).toMatch(/^(success|error)$/);
|
||||
});
|
||||
expect(mockIssuesApi.uploadAttachment).toHaveBeenCalledTimes(during === "create" ? 0 : 1);
|
||||
expect(dialogState.closeNewIssue).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem("paperclip:issue-draft:pending-create:user-1")).toContain(key);
|
||||
expect(container.textContent).not.toContain("Account A in-flight task");
|
||||
} finally { act(() => root.unmount()); queryClient.clear(); }
|
||||
});
|
||||
|
||||
it("restores original document keys when colliding document names are reattached in reverse", async () => {
|
||||
dialogState.newIssueDefaults = { title: "Has documents" };
|
||||
mockIssuesApi.create.mockResolvedValue({ id: "issue-1", identifier: "PAP-1", companyId: "company-1" });
|
||||
mockIssuesApi.upsertDocument.mockResolvedValue({});
|
||||
const markdown = new File(["a"], "notes.md", { type: "text/markdown", lastModified: 1 });
|
||||
const text = new File(["bb"], "notes.txt", { type: "text/plain", lastModified: 2 });
|
||||
localStorage.setItem("paperclip:issue-draft:create-awaiting-files:user-1", JSON.stringify({ version: 1, key: "paperclip:issue-draft:create-awaiting-files:user-1", idempotencyKey: "original-doc-create-key", createdAt: Date.now(), payload: { fingerprint: JSON.stringify({ stagedFiles: ["document:notes:notes.md:1:1", "document:notes-2:notes.txt:2:2"] }), data: { companyId: "company-1", title: "Has documents" }, stagedFileNames: ["notes.md", "notes.txt"] } }));
|
||||
const { root } = renderDialog(container);
|
||||
try {
|
||||
await waitForAssertion(() => expect(container.textContent).toContain("notes.md"));
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!;
|
||||
Object.defineProperty(input, "files", { configurable: true, value: [text, markdown] });
|
||||
await act(async () => { input.dispatchEvent(new Event("change", { bubbles: true })); }); await flush();
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Create task"]')!.click());
|
||||
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalledTimes(1));
|
||||
expect(mockIssuesApi.create.mock.calls[0][1].idempotencyKey).toBe("original-doc-create-key");
|
||||
await waitForAssertion(() => expect(mockIssuesApi.upsertDocument).toHaveBeenCalledTimes(2));
|
||||
expect(mockIssuesApi.upsertDocument).toHaveBeenCalledWith("issue-1", "notes", expect.objectContaining({ body: "a" }));
|
||||
expect(mockIssuesApi.upsertDocument).toHaveBeenCalledWith("issue-1", "notes-2", expect.objectContaining({ body: "bb" }));
|
||||
} finally { act(() => root.unmount()); }
|
||||
});
|
||||
|
||||
it("restores a saved subtask's parent and goal when opened from the normal New Task action", async () => {
|
||||
dialogState.newIssueDefaults = {};
|
||||
mockIssuesApi.create.mockResolvedValue({ id: "issue-1", identifier: "PAP-1", companyId: "company-1" });
|
||||
mockIssuesApi.uploadAttachment.mockResolvedValue({});
|
||||
mockIssuesApi.get.mockResolvedValue({ id: "parent-1", identifier: "PAP-2", title: "Parent", isPrivate: false });
|
||||
localStorage.setItem("paperclip:issue-draft:user-1", JSON.stringify({ title: "Saved subtask", description: "", status: "todo", priority: "", projectId: "", assigneeValue: "" }));
|
||||
localStorage.setItem("paperclip:issue-draft:create-awaiting-files:user-1", JSON.stringify({
|
||||
version: 1, key: "paperclip:issue-draft:create-awaiting-files:user-1", idempotencyKey: "subtask-key-123", createdAt: Date.now(),
|
||||
payload: { fingerprint: JSON.stringify({ stagedFiles: ["attachment::spec.pdf:1:1"] }),
|
||||
data: { companyId: "company-1", title: "Saved subtask", parentId: "parent-1", goalId: "goal-1" }, stagedFileNames: ["spec.pdf"] },
|
||||
}));
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!;
|
||||
Object.defineProperty(input, "files", { configurable: true, value: [new File(["a"], "spec.pdf", { lastModified: 1 })] });
|
||||
await act(async () => { input.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
await flush();
|
||||
await act(async () => { container.querySelector<HTMLButtonElement>('[aria-label="Create sub-task"]')!.click(); });
|
||||
await flush();
|
||||
expect(mockIssuesApi.create).toHaveBeenCalledTimes(1);
|
||||
expect(mockIssuesApi.create.mock.calls[0]?.[1]).toMatchObject({ idempotencyKey: "subtask-key-123", title: "Saved subtask", parentId: "parent-1", goalId: "goal-1" });
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("submits Chinese, Japanese, and Hindi issue text without normalization", async () => {
|
||||
const title = "验证中文任务";
|
||||
const description = [
|
||||
@@ -2035,7 +2330,7 @@ describe("NewIssueDialog", () => {
|
||||
enableIsolatedWorkspaces: false,
|
||||
});
|
||||
localStorage.setItem(
|
||||
"paperclip:issue-draft",
|
||||
"paperclip:issue-draft:user-1",
|
||||
JSON.stringify({
|
||||
title: "Watched task",
|
||||
description: "",
|
||||
@@ -2099,7 +2394,7 @@ describe("NewIssueDialog", () => {
|
||||
|
||||
it("restores shared model, effort, and fast settings into the created task", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([{ id: "agent-1", name: "Coder", status: "active", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} }]);
|
||||
localStorage.setItem("paperclip:issue-draft", JSON.stringify({
|
||||
localStorage.setItem("paperclip:issue-draft:user-1", JSON.stringify({
|
||||
title: "", description: "Use the saved run settings", status: "todo", priority: "medium",
|
||||
assigneeValue: "agent:agent-1", projectId: "", workMode: "planning",
|
||||
composerSettings: { model: "gpt-6-astra", effort: "ultra", fast: true },
|
||||
@@ -2135,3 +2430,70 @@ describe("NewIssueDialog", () => {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("addStagedFilesToIssue", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("crypto", webcrypto);
|
||||
localStorage.clear();
|
||||
mockIssuesApi.uploadAttachment.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("keeps a confirmed upload receipt when ownership changes before the next file", async () => {
|
||||
const files = ["one", "two"].map((name, index) => ({ id: name, kind: "attachment" as const, file: new File([name], `${name}.pdf`, { lastModified: index + 1 }) }));
|
||||
let owned = true;
|
||||
const assertCurrentOwner = () => { if (!owned) throw new Error("Account changed"); };
|
||||
mockIssuesApi.uploadAttachment.mockResolvedValue({}).mockImplementationOnce(async () => { owned = false; return {}; });
|
||||
const input = { companyId: "company-1", issueId: "task-1", stagedFiles: files, idempotencyKey: "original-upload-request", assertCurrentOwner };
|
||||
await expect(addStagedFilesToIssue(input)).rejects.toThrow("Account changed");
|
||||
expect(mockIssuesApi.uploadAttachment).toHaveBeenCalledTimes(1);
|
||||
owned = true;
|
||||
await expect(addStagedFilesToIssue(input)).resolves.toEqual([]);
|
||||
expect(mockIssuesApi.uploadAttachment.mock.calls.map(call => call[2].name)).toEqual(["one.pdf", "two.pdf"]);
|
||||
});
|
||||
|
||||
it("uploads distinct files with the same metadata instead of treating one as a receipt", async () => {
|
||||
const stagedFiles = ["a", "b"].map((body, index) => ({
|
||||
id: `staged-${index}`,
|
||||
kind: "attachment" as const,
|
||||
file: new File([body], "report.txt", { lastModified: 123 }),
|
||||
}));
|
||||
mockIssuesApi.uploadAttachment.mockResolvedValue({});
|
||||
expect(await addStagedFilesToIssue({ companyId: "company-1", issueId: "issue-1", idempotencyKey: "metadata-collision", stagedFiles })).toEqual([]);
|
||||
expect(mockIssuesApi.uploadAttachment).toHaveBeenCalledTimes(2);
|
||||
expect(mockIssuesApi.uploadAttachment.mock.calls[0]?.[2]).toBe(stagedFiles[0]!.file);
|
||||
expect(mockIssuesApi.uploadAttachment.mock.calls[1]?.[2]).toBe(stagedFiles[1]!.file);
|
||||
});
|
||||
|
||||
it("still uploads files when browser hashing is unavailable", async () => {
|
||||
vi.stubGlobal("crypto", {});
|
||||
const stagedFiles = ["a", "b"].map((body, index) => ({
|
||||
id: `staged-${index}`, kind: "attachment" as const,
|
||||
file: new File([body], "report.txt", { lastModified: 123 }),
|
||||
}));
|
||||
mockIssuesApi.uploadAttachment.mockResolvedValue({});
|
||||
expect(await addStagedFilesToIssue({ companyId: "company-1", issueId: "issue-1", idempotencyKey: "hashing-unavailable", stagedFiles })).toEqual([]);
|
||||
expect(mockIssuesApi.uploadAttachment).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("never adds a file twice when a create replays after a reload mid-upload", async () => {
|
||||
const spec = { id: "staged-1", kind: "attachment" as const, file: new File(["a"], "spec.pdf", { lastModified: 1 }) };
|
||||
const notes = { id: "staged-2", kind: "attachment" as const, file: new File(["bb"], "notes.txt", { lastModified: 2 }) };
|
||||
mockIssuesApi.uploadAttachment.mockResolvedValueOnce({}).mockRejectedValueOnce(new Error("Upload interrupted"));
|
||||
const target = { companyId: "company-1", issueId: "issue-1", idempotencyKey: "create-key-123" };
|
||||
expect(await addStagedFilesToIssue({ ...target, stagedFiles: [spec, notes] })).toEqual(["notes.txt"]);
|
||||
|
||||
// Reattached after a reload: new staging ids, the same files, the same key.
|
||||
mockIssuesApi.uploadAttachment.mockResolvedValue({});
|
||||
expect(await addStagedFilesToIssue({
|
||||
...target,
|
||||
stagedFiles: [{ ...spec, id: "staged-3" }, { ...notes, id: "staged-4" }],
|
||||
})).toEqual([]);
|
||||
expect(mockIssuesApi.uploadAttachment.mock.calls.map((call) => (call[2] as File).name)).toEqual([
|
||||
"spec.pdf",
|
||||
"notes.txt",
|
||||
"notes.txt",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -43,10 +43,74 @@ import { TaskChatPresentationProvider } from "./task-chat/presentation-mode";
|
||||
import { mergeComposerRunSettings, type ComposerRunSettings } from "./task-chat/composer-run-settings";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { InlineBanner } from "./InlineBanner";
|
||||
import { PendingSendNotice } from "./PendingSendNotice";
|
||||
import { useDurableSubmit } from "../hooks/useDurableSubmit";
|
||||
import { describeError, type ErrorDescription } from "../api/errors";
|
||||
import { clearPendingSend, pendingSendKeyFor, readPendingSend, writePendingSend, PendingSendAccountChangedError } from "../lib/pending-send";
|
||||
import { useDraftOwner, useDraftOwnerReader } from "../hooks/useDraftOwner";
|
||||
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
||||
import { getTrustPreset } from "../lib/trust-policy-ui";
|
||||
|
||||
const DRAFT_KEY = "paperclip:issue-draft";
|
||||
/** A create that is waiting to resend: its request body and idempotency key. */
|
||||
const PENDING_CREATE_KEY = "paperclip:issue-draft:pending-create";
|
||||
|
||||
/**
|
||||
* The create request. Staged files cannot be stored, so they ride in memory and
|
||||
* only their names are saved: a create restored after reload knows it needs them.
|
||||
*/
|
||||
type PendingCreate = { fingerprint: string; data: Record<string, unknown>; stagedFileNames?: string[] };
|
||||
|
||||
function isPendingCreate(value: unknown): value is PendingCreate {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const row = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof row.fingerprint === "string" &&
|
||||
!!row.data && typeof row.data === "object" && !Array.isArray(row.data) &&
|
||||
typeof (row.data as Record<string, unknown>).companyId === "string" &&
|
||||
(row.stagedFileNames === undefined ||
|
||||
(Array.isArray(row.stagedFileNames) && row.stagedFileNames.every((name) => typeof name === "string")))
|
||||
);
|
||||
}
|
||||
|
||||
/** Thrown before sending a restored create whose staged files are gone from memory. */
|
||||
class StagedFilesMissingError extends Error {
|
||||
constructor(names: string[]) {
|
||||
super(
|
||||
`This page no longer has ${names.length === 1 ? "the file" : `the ${names.length} files`} staged for this task (${names.join(", ")}). Attach ${names.length === 1 ? "it" : "them"} again, then create the task.`,
|
||||
);
|
||||
this.name = "StagedFilesMissingError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A restored create that could not be sent for lack of its files. Its key is
|
||||
* kept until a create succeeds or the user cancels, so reattaching after any
|
||||
* number of reloads replays a create that already reached the server.
|
||||
*/
|
||||
const CREATE_AWAITING_FILES_KEY = "paperclip:issue-draft:create-awaiting-files";
|
||||
|
||||
function stagedFilesMissingDescription(names: string[]): ErrorDescription {
|
||||
return describeError(new StagedFilesMissingError(names), { action: "create the task" });
|
||||
}
|
||||
|
||||
/** Identifies a staged file across reselection from disk, unlike its per-staging `id`. */
|
||||
function stagedFileIdentity(file: StagedIssueFile): string {
|
||||
return [file.kind, file.documentKey ?? "", file.file.name, file.file.size, file.file.lastModified].join(":");
|
||||
}
|
||||
function restoreStagedDocumentKeys(files: StagedIssueFile[], fingerprint: string | undefined): StagedIssueFile[] {
|
||||
let required: unknown;
|
||||
try { required = JSON.parse(fingerprint ?? "{}").stagedFiles; } catch { return files; }
|
||||
if (!Array.isArray(required)) return files;
|
||||
return files.map(file => {
|
||||
if (file.kind !== "document") return file;
|
||||
const suffix = `:${file.file.name}:${file.file.size}:${file.file.lastModified}`;
|
||||
const matches = required.filter((identity): identity is string => typeof identity === "string" && identity.startsWith("document:") && identity.endsWith(suffix));
|
||||
if (matches.length !== 1) return file;
|
||||
return { ...file, documentKey: matches[0].slice("document:".length, -suffix.length) };
|
||||
});
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 800;
|
||||
|
||||
type VisualViewportLayout = {
|
||||
@@ -146,12 +210,103 @@ type StagedIssueFile = {
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Files a keyed create has already added to its task. A replayed create (same
|
||||
* key, after a reload mid-upload) gets the existing task back from the
|
||||
* server, and must not add those files a second time.
|
||||
*/
|
||||
const CREATE_UPLOAD_RECEIPTS_PREFIX = "paperclip:issue-draft:create-uploads:";
|
||||
|
||||
function loadCreateUploadReceipts(idempotencyKey: string): Set<string> {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(CREATE_UPLOAD_RECEIPTS_PREFIX + idempotencyKey) ?? "[]");
|
||||
return new Set(Array.isArray(stored) ? stored.filter((item): item is string => typeof item === "string") : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function saveCreateUploadReceipts(idempotencyKey: string, receipts: Set<string>) {
|
||||
try {
|
||||
localStorage.setItem(CREATE_UPLOAD_RECEIPTS_PREFIX + idempotencyKey, JSON.stringify([...receipts]));
|
||||
} catch {
|
||||
// Without storage a replay after reload may add a file twice; nothing is lost.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCreateUploadReceipts(idempotencyKey: string) {
|
||||
try {
|
||||
localStorage.removeItem(CREATE_UPLOAD_RECEIPTS_PREFIX + idempotencyKey);
|
||||
} catch {
|
||||
// Unavailable storage holds nothing to clear.
|
||||
}
|
||||
}
|
||||
|
||||
/** Add staged files to a created task, skipping any this create already added. Returns the names that failed. */
|
||||
export async function addStagedFilesToIssue(input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
stagedFiles: StagedIssueFile[];
|
||||
idempotencyKey?: string;
|
||||
assertCurrentOwner?: () => void;
|
||||
}): Promise<string[]> {
|
||||
const { companyId, issueId, stagedFiles, idempotencyKey } = input;
|
||||
const receipts = idempotencyKey ? loadCreateUploadReceipts(idempotencyKey) : new Set<string>();
|
||||
const failures: string[] = [];
|
||||
for (const stagedFile of stagedFiles) {
|
||||
input.assertCurrentOwner?.();
|
||||
let identity: string | undefined;
|
||||
if (idempotencyKey && globalThis.crypto?.subtle) {
|
||||
try {
|
||||
const bytes = await new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.onabort = () => reject(new Error("File read cancelled"));
|
||||
reader.readAsArrayBuffer(stagedFile.file);
|
||||
});
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
const hash = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
identity = JSON.stringify([stagedFileIdentity(stagedFile), hash]);
|
||||
} catch {
|
||||
identity = undefined;
|
||||
}
|
||||
}
|
||||
input.assertCurrentOwner?.();
|
||||
if (identity && receipts.has(identity)) continue;
|
||||
try {
|
||||
if (stagedFile.kind === "document") {
|
||||
const body = await stagedFile.file.text();
|
||||
input.assertCurrentOwner?.();
|
||||
await issuesApi.upsertDocument(issueId, stagedFile.documentKey ?? "document", {
|
||||
title: stagedFile.documentKey === "plan" ? null : (stagedFile.title ?? null),
|
||||
format: "markdown",
|
||||
body,
|
||||
baseRevisionId: null,
|
||||
});
|
||||
} else {
|
||||
await issuesApi.uploadAttachment(companyId, issueId, stagedFile.file);
|
||||
}
|
||||
if (identity && idempotencyKey) {
|
||||
receipts.add(identity);
|
||||
saveCreateUploadReceipts(idempotencyKey, receipts);
|
||||
}
|
||||
input.assertCurrentOwner?.();
|
||||
} catch {
|
||||
input.assertCurrentOwner?.();
|
||||
failures.push(stagedFile.file.name);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buildAssigneeAdapterOverrides, type IssueModelLane } from "../lib/issue-assignee-overrides";
|
||||
|
||||
function loadDraft(): IssueDraft | null {
|
||||
function loadDraft(owner: string | undefined): IssueDraft | null {
|
||||
if (owner === undefined) return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(DRAFT_KEY);
|
||||
const raw = localStorage.getItem(pendingSendKeyFor(DRAFT_KEY, owner));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as IssueDraft;
|
||||
} catch {
|
||||
@@ -159,12 +314,12 @@ function loadDraft(): IssueDraft | null {
|
||||
}
|
||||
}
|
||||
|
||||
function saveDraft(draft: IssueDraft) {
|
||||
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
|
||||
function saveDraft(owner: string, draft: IssueDraft) {
|
||||
try { localStorage.setItem(pendingSendKeyFor(DRAFT_KEY, owner), JSON.stringify(draft)); } catch {}
|
||||
}
|
||||
|
||||
function clearDraft() {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
function clearDraft(owner: string | undefined) {
|
||||
try { if (owner !== undefined) localStorage.removeItem(pendingSendKeyFor(DRAFT_KEY, owner)); } catch {}
|
||||
}
|
||||
|
||||
function isTextDocumentFile(file: File) {
|
||||
@@ -277,8 +432,24 @@ function isWorkModeEscapeShortcut(e: Pick<KeyboardEvent, "key" | "metaKey">) {
|
||||
}
|
||||
|
||||
export function NewIssueDialog() {
|
||||
const owner = useDraftOwner();
|
||||
const { newIssueDefaults } = useDialog();
|
||||
const [scope, setScope] = useState({ owner, generation: 0, ignoredDefaults: null as typeof newIssueDefaults | null });
|
||||
const changed = scope.owner !== owner;
|
||||
const knownChange = changed && scope.owner !== undefined;
|
||||
const next = changed ? { owner, generation: scope.generation + (knownChange ? 1 : 0), ignoredDefaults: knownChange ? newIssueDefaults : scope.ignoredDefaults } : scope;
|
||||
if (changed) setScope(next);
|
||||
return <AccountNewIssueDialog key={next.generation} ignoreDefaults={next.ignoredDefaults === newIssueDefaults} />;
|
||||
}
|
||||
|
||||
function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean }) {
|
||||
const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls();
|
||||
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
|
||||
const { newIssueOpen, newIssueDefaults: sharedDefaults, closeNewIssue } = useDialog();
|
||||
const newIssueDefaults = useMemo(() => ignoreDefaults ? {} as typeof sharedDefaults : sharedDefaults, [ignoreDefaults, sharedDefaults]);
|
||||
const readOwner = useDraftOwnerReader();
|
||||
const active = useRef(true);
|
||||
useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
|
||||
const unboundFormDraft = useRef<IssueDraft | null>(null);
|
||||
const visualViewportLayout = useVisualViewportLayout(newIssueOpen);
|
||||
const dialogBodyRef = useRef<HTMLDivElement>(null);
|
||||
const { companies, selectedCompanyId: effectiveCompanyId } = useCompany();
|
||||
@@ -317,12 +488,24 @@ export function NewIssueDialog() {
|
||||
const initializationKeyRef = useRef<string | null>(null);
|
||||
const defaultAssigneePendingRef = useRef(false);
|
||||
const defaultProjectPendingRef = useRef(false);
|
||||
const createRequestRef = useRef<{ fingerprint: string; idempotencyKey: string } | null>(null);
|
||||
// `recovering`: a create restored without its files keeps its key until it is
|
||||
// sent or cancelled, whatever the files and fields look like when resubmitted.
|
||||
const createRequestRef = useRef<{ fingerprint: string; idempotencyKey: string; recovering?: boolean; payload?: PendingCreate } | null>(null);
|
||||
const draftOwner = useDraftOwner();
|
||||
const [restoredLaunch, setRestoredLaunch] = useState<{ owner: string | undefined; parentId?: string; goalId?: string } | null>(null);
|
||||
const savedLaunch = restoredLaunch?.owner === draftOwner ? restoredLaunch : null;
|
||||
const parentId = savedLaunch ? savedLaunch.parentId : newIssueDefaults.parentId;
|
||||
const goalId = savedLaunch ? savedLaunch.goalId : newIssueDefaults.goalId;
|
||||
const restoreLaunch = (payload: PendingCreate) => setRestoredLaunch({ owner: draftOwner,
|
||||
...(typeof payload.data.parentId === "string" ? { parentId: payload.data.parentId } : {}),
|
||||
...(typeof payload.data.goalId === "string" ? { goalId: payload.data.goalId } : {}),
|
||||
});
|
||||
const awaitingFilesKey = draftOwner !== undefined ? pendingSendKeyFor(CREATE_AWAITING_FILES_KEY, draftOwner) : null;
|
||||
|
||||
const primaryAgent = usePrimaryAgentPresentation(effectiveCompanyId);
|
||||
const isSubIssueMode = Boolean(newIssueDefaults.parentId);
|
||||
const isSubIssueMode = Boolean(parentId);
|
||||
const parentIssueLabel =
|
||||
newIssueDefaults.parentIdentifier ?? (newIssueDefaults.parentId ? newIssueDefaults.parentId.slice(0, 8) : "");
|
||||
(newIssueDefaults.parentId === parentId ? newIssueDefaults.parentIdentifier : undefined) ?? (parentId ? parentId.slice(0, 8) : "");
|
||||
const parentExecutionWorkspaceId = newIssueDefaults.executionWorkspaceId ?? "";
|
||||
const parentExecutionWorkspaceLabel = newIssueDefaults.parentExecutionWorkspaceLabel ?? parentExecutionWorkspaceId;
|
||||
|
||||
@@ -391,9 +574,9 @@ export function NewIssueDialog() {
|
||||
});
|
||||
|
||||
const { data: privacyParent, isError: parentPrivacyError, refetch: refetchParentPrivacy } = useQuery({
|
||||
queryKey: queryKeys.issues.detail(newIssueDefaults.parentId ?? ""),
|
||||
queryFn: () => issuesApi.get(newIssueDefaults.parentId!),
|
||||
enabled: newIssueOpen && Boolean(newIssueDefaults.parentId),
|
||||
queryKey: queryKeys.issues.detail(parentId ?? ""),
|
||||
queryFn: () => issuesApi.get(parentId!),
|
||||
enabled: newIssueOpen && Boolean(parentId),
|
||||
});
|
||||
const parentPrivacyUnresolved = isSubIssueMode && !privacyParent;
|
||||
const inheritsPrivateAccess = privacyParent?.visibility === "private" || privacyParent?.project?.visibility === "private";
|
||||
@@ -423,6 +606,9 @@ export function NewIssueDialog() {
|
||||
});
|
||||
}, [agents, companyMembers?.users, orderedProjects, mentionIssues]);
|
||||
|
||||
const assertCurrentOwner = () => {
|
||||
if (!active.current || draftOwner === undefined || readOwner() !== draftOwner) throw new PendingSendAccountChangedError();
|
||||
};
|
||||
const createIssue = useMutation({
|
||||
mutationFn: async ({
|
||||
companyId,
|
||||
@@ -430,30 +616,22 @@ export function NewIssueDialog() {
|
||||
navigateOnCreate,
|
||||
...data
|
||||
}: { companyId: string; stagedFiles: StagedIssueFile[]; navigateOnCreate?: boolean } & Record<string, unknown>) => {
|
||||
assertCurrentOwner();
|
||||
const issue = await issuesApi.create(companyId, data);
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const stagedFile of pendingStagedFiles) {
|
||||
try {
|
||||
if (stagedFile.kind === "document") {
|
||||
const body = await stagedFile.file.text();
|
||||
await issuesApi.upsertDocument(issue.id, stagedFile.documentKey ?? "document", {
|
||||
title: stagedFile.documentKey === "plan" ? null : (stagedFile.title ?? null),
|
||||
format: "markdown",
|
||||
body,
|
||||
baseRevisionId: null,
|
||||
});
|
||||
} else {
|
||||
await issuesApi.uploadAttachment(companyId, issue.id, stagedFile.file);
|
||||
}
|
||||
} catch {
|
||||
failures.push(stagedFile.file.name);
|
||||
}
|
||||
}
|
||||
assertCurrentOwner();
|
||||
const failures = await addStagedFilesToIssue({
|
||||
companyId,
|
||||
issueId: issue.id,
|
||||
stagedFiles: pendingStagedFiles,
|
||||
assertCurrentOwner,
|
||||
idempotencyKey: typeof data.idempotencyKey === "string" ? data.idempotencyKey : undefined,
|
||||
});
|
||||
|
||||
assertCurrentOwner();
|
||||
return { issue, companyId, failures, navigateOnCreate };
|
||||
},
|
||||
onSuccess: ({ issue, companyId, failures, navigateOnCreate }) => {
|
||||
assertCurrentOwner();
|
||||
trackRecentProject(issue.projectId ?? "", companyId);
|
||||
if (issue.assigneeAgentId) trackRecentAssignee(issue.assigneeAgentId, companyId);
|
||||
if (issue.assigneeUserId) trackRecentAssigneeUser(issue.assigneeUserId, companyId);
|
||||
@@ -483,20 +661,94 @@ export function NewIssueDialog() {
|
||||
action: openIssueAction,
|
||||
});
|
||||
}
|
||||
clearDraft();
|
||||
clearDraft(draftOwner);
|
||||
reset();
|
||||
closeNewIssue();
|
||||
if (navigateOnCreate) navigate(openIssueAction?.href ?? `/issues/${issueRef}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Issue create is keyed: the server replays the original issue for a known
|
||||
// idempotency key, so an outage or a lost response resends safely.
|
||||
const stagedFilesByKey = useRef(new Map<string, StagedIssueFile[]>());
|
||||
// The composer shows a rejection of the first attempt; later (automatic or
|
||||
// Resend now) rejections show here.
|
||||
const firstAttemptKey = useRef<string | null>(null);
|
||||
const [resendError, setResendError] = useState<ErrorDescription | null>(null);
|
||||
// Once the account is known, a create still waiting for its files keeps its
|
||||
// key and asks for the files again.
|
||||
useEffect(() => {
|
||||
if (!awaitingFilesKey) return;
|
||||
const awaiting = readPendingSend(awaitingFilesKey, isPendingCreate);
|
||||
if (!awaiting) return;
|
||||
createRequestRef.current ??= {
|
||||
fingerprint: awaiting.payload.fingerprint,
|
||||
idempotencyKey: awaiting.idempotencyKey,
|
||||
recovering: true,
|
||||
payload: awaiting.payload,
|
||||
};
|
||||
restoreLaunch(awaiting.payload);
|
||||
const names = awaiting.payload.stagedFileNames;
|
||||
if (names?.length) setResendError((current) => current ?? stagedFilesMissingDescription(names));
|
||||
}, [awaitingFilesKey]);
|
||||
const durableCreate = useDurableSubmit<PendingCreate, unknown>({
|
||||
sourceId: "new-issue-dialog",
|
||||
storageKey: PENDING_CREATE_KEY,
|
||||
isPayload: isPendingCreate,
|
||||
action: "create the task",
|
||||
send: async (payload, idempotencyKey) => {
|
||||
const stagedFiles = stagedFilesByKey.current.get(idempotencyKey);
|
||||
// Never create a restored task without the files it was submitted with.
|
||||
if (!stagedFiles && payload.stagedFileNames?.length) {
|
||||
throw new StagedFilesMissingError(payload.stagedFileNames);
|
||||
}
|
||||
const result = await createIssue.mutateAsync({
|
||||
...(payload.data as { companyId: string }),
|
||||
stagedFiles: stagedFiles ?? [],
|
||||
idempotencyKey,
|
||||
});
|
||||
stagedFilesByKey.current.delete(idempotencyKey);
|
||||
clearCreateUploadReceipts(idempotencyKey);
|
||||
return result;
|
||||
},
|
||||
onRejected: (error, description, pending) => {
|
||||
if (error instanceof StagedFilesMissingError) {
|
||||
// Reattaching the same files resubmits under the original key, so a
|
||||
// create that already reached the server is replayed, not duplicated.
|
||||
createRequestRef.current = {
|
||||
fingerprint: pending.payload.fingerprint,
|
||||
idempotencyKey: pending.idempotencyKey,
|
||||
recovering: true,
|
||||
payload: pending.payload,
|
||||
};
|
||||
restoreLaunch(pending.payload);
|
||||
if (awaitingFilesKey) writePendingSend(awaitingFilesKey, pending);
|
||||
}
|
||||
if (pending.idempotencyKey !== firstAttemptKey.current) setResendError(description);
|
||||
},
|
||||
});
|
||||
// A create restored after reload without its files cannot be sent; say so
|
||||
// now rather than after the first resend delay. Nothing reaches the network.
|
||||
const pendingCreate = durableCreate.pending;
|
||||
const { resendNow: resendCreateNow } = durableCreate;
|
||||
useEffect(() => {
|
||||
if (pendingCreate?.payload.stagedFileNames?.length && !stagedFilesByKey.current.has(pendingCreate.idempotencyKey)) {
|
||||
resendCreateNow();
|
||||
}
|
||||
}, [pendingCreate, resendCreateNow]);
|
||||
|
||||
// Debounced draft saving
|
||||
const scheduleSave = useCallback((draft: IssueDraft) => {
|
||||
if (draftOwner === undefined && (unboundFormDraft.current || draft.title.trim() || draft.description.trim())) unboundFormDraft.current = draft;
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
draftTimer.current = setTimeout(() => {
|
||||
if (draft.title.trim() || draft.description.trim()) saveDraft(draft);
|
||||
const owner = readOwner();
|
||||
if (owner !== undefined && (draftOwner === undefined || owner === draftOwner)) {
|
||||
if (draft.title.trim() || draft.description.trim()) saveDraft(owner, draft);
|
||||
else clearDraft(owner);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
}, []);
|
||||
}, [draftOwner, readOwner]);
|
||||
|
||||
const setIssueText = useCallback((nextTitle: string, nextDescription: string) => {
|
||||
setHasTitle(Boolean(nextTitle));
|
||||
@@ -600,13 +852,23 @@ export function NewIssueDialog() {
|
||||
defaultProjectPendingRef.current = false;
|
||||
return;
|
||||
}
|
||||
const initializationKey = `${effectiveCompanyId ?? ""}:${JSON.stringify(newIssueDefaults)}`;
|
||||
const initializationKey = `${draftOwner ?? "unknown"}:${effectiveCompanyId ?? ""}:${JSON.stringify(newIssueDefaults)}`;
|
||||
if (initializationKeyRef.current === initializationKey) return;
|
||||
initializationKeyRef.current = initializationKey;
|
||||
if (draftTimer.current) {
|
||||
clearTimeout(draftTimer.current);
|
||||
draftTimer.current = null;
|
||||
}
|
||||
|
||||
executionWorkspaceDefaultProjectId.current = null;
|
||||
|
||||
const draft = loadDraft();
|
||||
if (draftOwner !== undefined && unboundFormDraft.current) {
|
||||
if (unboundFormDraft.current.title.trim() || unboundFormDraft.current.description.trim()) saveDraft(draftOwner, unboundFormDraft.current);
|
||||
else clearDraft(draftOwner);
|
||||
unboundFormDraft.current = null;
|
||||
return;
|
||||
}
|
||||
const draft = loadDraft(draftOwner);
|
||||
defaultProjectPendingRef.current = newIssueDefaults.projectId === undefined && !newIssueDefaults.parentId;
|
||||
defaultAssigneePendingRef.current = !newIssueDefaults.assigneeAgentId && !newIssueDefaults.assigneeUserId;
|
||||
setComposerSettings(null);
|
||||
@@ -745,7 +1007,7 @@ export function NewIssueDialog() {
|
||||
? defaultProjectId || null
|
||||
: null;
|
||||
}
|
||||
}, [newIssueOpen, newIssueDefaults, orderedProjects, effectiveCompanyId, setIssueText]);
|
||||
}, [newIssueOpen, newIssueDefaults, orderedProjects, effectiveCompanyId, setIssueText, draftOwner]);
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
@@ -755,7 +1017,9 @@ export function NewIssueDialog() {
|
||||
}, []);
|
||||
|
||||
function reset() {
|
||||
createRequestRef.current = null;
|
||||
unboundFormDraft.current = null;
|
||||
createRequestRef.current = null; setRestoredLaunch(null);
|
||||
if (awaitingFilesKey) clearPendingSend(awaitingFilesKey);
|
||||
setIssueText("", "");
|
||||
setStatus("todo");
|
||||
setPriority("");
|
||||
@@ -789,7 +1053,7 @@ export function NewIssueDialog() {
|
||||
async function handleSubmit(body: string, mode: IssueWorkMode, settings: ComposerRunSettings | null) {
|
||||
const currentTitle = titleRef.current.trim();
|
||||
const currentDescription = body.trim();
|
||||
if (!effectiveCompanyId || (!currentTitle && !currentDescription) || createIssue.isPending || worktreeSelectionIncomplete || parentPrivacyUnresolved) return;
|
||||
if (!effectiveCompanyId || (!currentTitle && !currentDescription) || createIssue.isPending || durableCreate.pending || worktreeSelectionIncomplete || parentPrivacyUnresolved) return;
|
||||
const inheritedOverrides = buildAssigneeAdapterOverrides({
|
||||
adapterType: assigneeAdapterType,
|
||||
lane: assigneeChrome ? "custom" : assigneeModelLane,
|
||||
@@ -833,7 +1097,6 @@ export function NewIssueDialog() {
|
||||
: inheritedOverrides;
|
||||
const createData = {
|
||||
companyId: effectiveCompanyId,
|
||||
stagedFiles,
|
||||
...(currentTitle ? { title: currentTitle } : {}),
|
||||
description: currentDescription || undefined,
|
||||
status,
|
||||
@@ -842,8 +1105,8 @@ export function NewIssueDialog() {
|
||||
...(effectivePrivate ? { visibility: "private" } : {}),
|
||||
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
|
||||
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
|
||||
...(newIssueDefaults.parentId ? { parentId: newIssueDefaults.parentId } : {}),
|
||||
...(newIssueDefaults.goalId ? { goalId: newIssueDefaults.goalId } : {}),
|
||||
...(parentId ? { parentId } : {}),
|
||||
...(goalId ? { goalId } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
...(requestedProjectWorkspaceId ? { projectWorkspaceId: requestedProjectWorkspaceId } : {}),
|
||||
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
||||
@@ -863,17 +1126,59 @@ export function NewIssueDialog() {
|
||||
: {}),
|
||||
};
|
||||
// An explicit board create is a new task even when its title already exists.
|
||||
// Reuse the request key only for retries of the same submitted draft.
|
||||
const fingerprint = JSON.stringify(createData);
|
||||
if (createRequestRef.current?.fingerprint !== fingerprint) {
|
||||
createRequestRef.current = { fingerprint, idempotencyKey: createUuid() };
|
||||
// Reuse the request key only for retries of the same submitted draft, and
|
||||
// always for a create waiting for its files: it may already have reached
|
||||
// the server, and its key then replays that task instead of a second one.
|
||||
// The file set is compared without regard to selection order.
|
||||
let request = createRequestRef.current;
|
||||
const createFiles = request?.recovering ? restoreStagedDocumentKeys(stagedFiles, request.payload?.fingerprint) : stagedFiles;
|
||||
const fingerprint = JSON.stringify({ ...createData, stagedFiles: createFiles.map(stagedFileIdentity).sort() });
|
||||
if (!request || (!request.recovering && request.fingerprint !== fingerprint)) {
|
||||
request = { fingerprint, idempotencyKey: createUuid() };
|
||||
createRequestRef.current = request;
|
||||
}
|
||||
await createIssue.mutateAsync({
|
||||
...createData,
|
||||
allowDuplicate: true,
|
||||
idempotencyKey: createRequestRef.current.idempotencyKey,
|
||||
navigateOnCreate: newIssueDefaults.navigateOnCreate === true,
|
||||
});
|
||||
let createPayload: PendingCreate = {
|
||||
fingerprint, data: { ...createData, allowDuplicate: true, navigateOnCreate: newIssueDefaults.navigateOnCreate === true },
|
||||
...(stagedFiles.length ? { stagedFileNames: stagedFiles.map(file => file.file.name) } : {}),
|
||||
};
|
||||
if (request.recovering) {
|
||||
const original = request.payload;
|
||||
const rejectRecovery = (message: string): never => {
|
||||
setResendError({ title: "Saved request needs attention", body: message, retryable: false });
|
||||
throw new Error(message);
|
||||
};
|
||||
if (!original) rejectRecovery("The saved request is unavailable. Cancel it before creating another task.");
|
||||
let originalFingerprint: Record<string, unknown> = {};
|
||||
try { originalFingerprint = JSON.parse(original!.fingerprint); } catch {}
|
||||
const requiredFiles = originalFingerprint.stagedFiles;
|
||||
if (original!.stagedFileNames?.length && (!Array.isArray(requiredFiles) ||
|
||||
JSON.stringify([...requiredFiles].sort()) !== JSON.stringify(createFiles.map(stagedFileIdentity).sort()))) {
|
||||
rejectRecovery(`Attach the original files again: ${original!.stagedFileNames.join(", ")}.`);
|
||||
}
|
||||
const normalize = (_key: string, value: unknown) => value && typeof value === "object" && !Array.isArray(value)
|
||||
? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value;
|
||||
const fieldsChanged = Object.entries(original!.data).some(([key, value]) =>
|
||||
!["allowDuplicate", "navigateOnCreate"].includes(key) && JSON.stringify(value, normalize) !== JSON.stringify((createData as Record<string, unknown>)[key], normalize));
|
||||
if (fieldsChanged || (typeof originalFingerprint.companyId === "string" &&
|
||||
JSON.stringify(originalFingerprint, normalize) !== JSON.stringify(JSON.parse(fingerprint), normalize))) {
|
||||
rejectRecovery("Restore the saved task's original fields before retrying, or cancel the saved request to start another task.");
|
||||
}
|
||||
createPayload = original!;
|
||||
}
|
||||
request.payload = createPayload;
|
||||
const { idempotencyKey } = request;
|
||||
stagedFilesByKey.current.set(idempotencyKey, createFiles);
|
||||
firstAttemptKey.current = idempotencyKey;
|
||||
setResendError(null);
|
||||
let result;
|
||||
try {
|
||||
result = await durableCreate.submit(createPayload, { idempotencyKey });
|
||||
} finally {
|
||||
firstAttemptKey.current = null;
|
||||
}
|
||||
// A transient failure keeps the request and shows the pending notice; a
|
||||
// rejection goes back to the composer with readable copy.
|
||||
if (result.kind === "rejected") throw result.error;
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
@@ -885,7 +1190,7 @@ export function NewIssueDialog() {
|
||||
}
|
||||
|
||||
function stageFiles(files: File[]) {
|
||||
if (files.length === 0 || createIssue.isPending) return;
|
||||
if (files.length === 0 || createIssue.isPending || durableCreate.pending) return;
|
||||
setStagedFiles((current) => {
|
||||
const next = [...current];
|
||||
for (const file of files) {
|
||||
@@ -1200,13 +1505,44 @@ export function NewIssueDialog() {
|
||||
runSettings: composerSettings,
|
||||
onRunSettingsChange: setComposerSettings,
|
||||
header:
|
||||
hasTitle || isSubIssueMode ? (
|
||||
hasTitle || isSubIssueMode || durableCreate.pending || resendError ? (
|
||||
<div className="mb-3 flex flex-col gap-2 text-xs">
|
||||
{durableCreate.pending && durableCreate.status !== "sending" ? (
|
||||
<PendingSendNotice
|
||||
noun="task"
|
||||
persisted={durableCreate.persisted}
|
||||
resending={durableCreate.resending}
|
||||
stalled={durableCreate.status === "stalled"}
|
||||
onResendNow={durableCreate.resendNow}
|
||||
onCancel={() => {
|
||||
// The draft (title and description) is still saved; only the send stops.
|
||||
const cancelledKey = durableCreate.pending?.idempotencyKey;
|
||||
const cancelled = durableCreate.cancel();
|
||||
if (cancelled) {
|
||||
createRequestRef.current = null; setRestoredLaunch(null);
|
||||
if (awaitingFilesKey) clearPendingSend(awaitingFilesKey, cancelledKey);
|
||||
if (cancelledKey) clearCreateUploadReceipts(cancelledKey);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{resendError && !durableCreate.pending ? (
|
||||
<div>
|
||||
<p role="alert" className="text-sm text-destructive">{resendError.title}. {resendError.body}</p>
|
||||
{createRequestRef.current?.recovering ? <Button type="button" size="sm" variant="ghost" onClick={() => {
|
||||
const key = createRequestRef.current?.idempotencyKey;
|
||||
if (awaitingFilesKey && key) clearPendingSend(awaitingFilesKey, key);
|
||||
if (key) { stagedFilesByKey.current.delete(key); clearCreateUploadReceipts(key); }
|
||||
createRequestRef.current = null; setRestoredLaunch(null); setResendError(null);
|
||||
}}>Cancel saved request</Button> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{hasTitle ? (
|
||||
<input
|
||||
aria-label="Task title"
|
||||
value={title}
|
||||
disabled={createIssue.isPending}
|
||||
// The waiting request is what gets created; later edits would be lost.
|
||||
disabled={createIssue.isPending || Boolean(durableCreate.pending)}
|
||||
className="w-full bg-transparent text-sm text-foreground outline-none"
|
||||
onChange={(event) => {
|
||||
titleRef.current = event.target.value;
|
||||
@@ -1378,7 +1714,8 @@ export function NewIssueDialog() {
|
||||
) : null}
|
||||
</>
|
||||
),
|
||||
submitDisabled: worktreeSelectionIncomplete || parentPrivacyUnresolved,
|
||||
submitDisabled: worktreeSelectionIncomplete || parentPrivacyUnresolved || Boolean(durableCreate.pending),
|
||||
readOnly: Boolean(durableCreate.pending),
|
||||
contextBar: (
|
||||
<>
|
||||
<InlineEntitySelector
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Shown above a composer while a send waits for the connection. The text stays
|
||||
* on this device and resends with its original request ID, so it cannot post
|
||||
* twice; the user can resend now or take the text back with Cancel.
|
||||
*
|
||||
* Without `onResendNow` (a send saved by an older version, with no stored
|
||||
* request to replay) it only says the outcome is unknown and offers Cancel.
|
||||
*/
|
||||
export function PendingSendNotice({
|
||||
noun = "message",
|
||||
resending,
|
||||
stalled,
|
||||
persisted = false,
|
||||
onResendNow,
|
||||
onCancel,
|
||||
className,
|
||||
}: {
|
||||
/** What is being sent: "comment", "task", "update". */
|
||||
noun?: string;
|
||||
resending: boolean;
|
||||
stalled: boolean;
|
||||
persisted?: boolean;
|
||||
onResendNow?: () => void;
|
||||
onCancel: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const replayable = Boolean(onResendNow);
|
||||
const title = !replayable
|
||||
? `We couldn’t confirm whether this ${noun} was sent.`
|
||||
: resending
|
||||
? "Sending…"
|
||||
: stalled
|
||||
? `Couldn’t send this ${noun} yet.`
|
||||
: "Sending when reconnected…";
|
||||
const detail = replayable
|
||||
? persisted ? `Your ${noun} is saved on this device.`
|
||||
: "Browser storage is unavailable. Keep this tab open or copy your text before leaving."
|
||||
: "It may already be in the conversation. Check it before sending again; Cancel keeps your text.";
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="pending-send-notice"
|
||||
className={cn("space-y-2 rounded-md border border-border bg-muted p-3 text-sm", className)}
|
||||
>
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
{resending ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-muted-foreground">{detail}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{onResendNow ? (
|
||||
<Button type="button" size="sm" variant="outline" disabled={resending} onClick={onResendNow}>
|
||||
Resend now
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" size="sm" variant="ghost" disabled={resending} onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -513,7 +513,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
userProfileMap,
|
||||
currentUserId,
|
||||
onAdd,
|
||||
onReviewConversation,
|
||||
onCancelRun,
|
||||
stopPending,
|
||||
stopScope,
|
||||
@@ -3309,7 +3308,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
comment.authorUserId === currentUserId && comment.clientRequestId &&
|
||||
!("clientStatus" in comment && comment.clientStatus)
|
||||
).map((comment) => comment.clientRequestId!))}
|
||||
onReviewConversation={onReviewConversation}
|
||||
onStop={liveRun ? onCancelRun : undefined}
|
||||
stopPending={stopPending}
|
||||
stopScope={stopScope}
|
||||
|
||||
@@ -9,6 +9,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ExternallyConnectedTaskBanner } from "./ExternallyConnectedTaskBanner";
|
||||
import { boardSendDraftKey, readBoardSendDraft } from "./board-send-draft";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
const mockSession = vi.hoisted(() => ({ userId: "user-1" }));
|
||||
vi.mock("@/api/auth", () => ({
|
||||
authApi: { getSession: vi.fn(async () => ({ user: { id: mockSession.userId } })) },
|
||||
}));
|
||||
|
||||
const mockChatEndpointsApi = vi.hoisted(() => ({
|
||||
getIssueBinding: vi.fn(),
|
||||
@@ -20,6 +25,19 @@ const uploadAttachmentMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/api/issues", () => ({
|
||||
issuesApi: { uploadAttachment: uploadAttachmentMock },
|
||||
}));
|
||||
// Lets a test report that automatic resends stopped, without running eight
|
||||
// backoff rounds; otherwise the real hook runs.
|
||||
const autoResendOverride = vi.hoisted(() => ({ stalled: false }));
|
||||
vi.mock("@/hooks/useDurableSubmit", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/hooks/useDurableSubmit")>();
|
||||
return {
|
||||
...actual,
|
||||
useAutoResend: (options: Parameters<typeof actual.useAutoResend>[0]) => {
|
||||
const result = actual.useAutoResend(options);
|
||||
return autoResendOverride.stalled ? { ...result, stalled: true } : result;
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@/hooks/useChatConnectorsEnabled", async (importOriginal) => ({
|
||||
...await importOriginal<typeof import("@/hooks/useChatConnectorsEnabled")>(),
|
||||
useChatConnectorsEnabled: () => ({ enabled: true, githubEnabled: true, loaded: true }),
|
||||
@@ -99,6 +117,7 @@ describe("ExternallyConnectedTaskBanner publication truth", () => {
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
if (!existingQueryClient) queryClient.setQueryData(queryKeys.auth.session, { user: { id: mockSession.userId } });
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -131,6 +150,7 @@ describe("ExternallyConnectedTaskBanner publication truth", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSession.userId = "user-1";
|
||||
sessionStorage.clear();
|
||||
mockChatEndpointsApi.getPublicationBatchStatus.mockImplementation(
|
||||
() => new Promise(() => {}),
|
||||
@@ -154,6 +174,131 @@ describe("ExternallyConnectedTaskBanner publication truth", () => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
autoResendOverride.stalled = false;
|
||||
});
|
||||
|
||||
it("cancels an automatic resend and hides its draft after an account change", async () => {
|
||||
mockChatEndpointsApi.publishBoardMessage.mockRejectedValue(new ApiError("Bad gateway", 502, {}));
|
||||
const client = await renderBanner();
|
||||
await composeAndSubmit("Private account A message");
|
||||
expect(mockChatEndpointsApi.publishBoardMessage).toHaveBeenCalledTimes(1);
|
||||
const retainedA = [...Array(sessionStorage.length)].map((_, index) => sessionStorage.getItem(sessionStorage.key(index)!));
|
||||
mockSession.userId = "user-2";
|
||||
await act(() => { client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } }); });
|
||||
await flushReact();
|
||||
await new Promise(resolve => setTimeout(resolve, 1_100));
|
||||
await flushReact();
|
||||
expect(mockChatEndpointsApi.publishBoardMessage).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector("textarea")?.value ?? "").not.toContain("Private account A message");
|
||||
expect(container.textContent).not.toContain("Sending when reconnected");
|
||||
expect([...Array(sessionStorage.length)].map((_, index) => sessionStorage.getItem(sessionStorage.key(index)!))).toEqual(retainedA);
|
||||
client.clear();
|
||||
});
|
||||
|
||||
it("ignores an old account's late receipt after the session changes", async () => {
|
||||
let finish!: (result: unknown) => void;
|
||||
mockChatEndpointsApi.publishBoardMessage.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
||||
const client = await renderBanner();
|
||||
await composeAndSubmit("Private pending account A message");
|
||||
mockSession.userId = "user-2";
|
||||
await act(() => { client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } }); });
|
||||
await flushReact();
|
||||
await act(() => { finish({ id: "publication-old", state: "published", attempts: 1 }); });
|
||||
await flushReact();
|
||||
expect(pushToastMock).not.toHaveBeenCalled();
|
||||
expect(container.querySelector("textarea")?.value ?? "").not.toContain("Private pending account A message");
|
||||
expect(sessionStorage.length).toBe(1);
|
||||
client.clear();
|
||||
});
|
||||
|
||||
it("restores only the original account's retained send after returning to it", async () => {
|
||||
mockChatEndpointsApi.publishBoardMessage.mockRejectedValue(new ApiError("Bad gateway", 502, {}));
|
||||
const client = await renderBanner();
|
||||
await composeAndSubmit("Account A retained request");
|
||||
const originalKey = mockChatEndpointsApi.publishBoardMessage.mock.calls[0][3];
|
||||
mockSession.userId = "user-2";
|
||||
await act(() => { client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } }); });
|
||||
await flushReact();
|
||||
await act(() => findButton(container, "Send to channel").click());
|
||||
expect(container.querySelector("textarea")?.value).toBe("");
|
||||
mockSession.userId = "user-1";
|
||||
await act(() => { client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); });
|
||||
await flushReact();
|
||||
expect(container.querySelector("textarea")?.value).toBe("Account A retained request");
|
||||
expect(mockChatEndpointsApi.publishBoardMessage).toHaveBeenCalledTimes(1);
|
||||
mockChatEndpointsApi.publishBoardMessage.mockResolvedValueOnce({ id: "publication-recovered", state: "published", attempts: 1 });
|
||||
await act(() => findButton(container, "Retry safely").click());
|
||||
await flushReact();
|
||||
expect(mockChatEndpointsApi.publishBoardMessage).toHaveBeenLastCalledWith("endpoint-1", "conversation-1", "Account A retained request", originalKey, []);
|
||||
});
|
||||
|
||||
it("does not reveal or replay an older retained send without a known owner", async () => {
|
||||
const key = boardSendDraftKey("company-1", "issue-1", "endpoint-1", "conversation-1");
|
||||
const saved = JSON.stringify({ body: "Unowned private message", attachmentIds: [], idempotencyKey: "legacy-request-key-1", publication: null });
|
||||
sessionStorage.setItem(key, saved);
|
||||
await renderBanner();
|
||||
expect(container.querySelector("textarea")?.value).toBe("");
|
||||
expect(container.querySelector("textarea")?.disabled).toBe(true);
|
||||
expect(container.textContent).toContain("Open Activity");
|
||||
expect(container.textContent).not.toContain("Unowned private message");
|
||||
expect(mockChatEndpointsApi.publishBoardMessage).not.toHaveBeenCalled();
|
||||
expect(sessionStorage.getItem(key)).toBe(saved);
|
||||
});
|
||||
|
||||
it.each([false, true])("can explicitly discard an unowned receipt without replaying its message (storage failure=%s)", async (storageFailure) => {
|
||||
const key = boardSendDraftKey("company-1", "issue-1", "endpoint-1", "conversation-1");
|
||||
const saved = JSON.stringify({ body: "Unowned private message", attachmentIds: [], idempotencyKey: "legacy-request-key-1", publication: null });
|
||||
sessionStorage.setItem(key, saved);
|
||||
await renderBanner();
|
||||
await act(() => findButton(container, "Discard saved delivery record").click());
|
||||
await flushReact();
|
||||
expect(document.body.textContent).toContain("does not cancel an update");
|
||||
const dialog = document.body.querySelector<HTMLElement>('[role="alertdialog"]')!;
|
||||
expect(dialog).toBeTruthy();
|
||||
await act(() => findButton(dialog, "Keep record").click());
|
||||
await flushReact();
|
||||
expect(sessionStorage.getItem(key)).toBe(saved);
|
||||
expect(container.querySelector("textarea")?.disabled).toBe(true);
|
||||
if (storageFailure) {
|
||||
const remove = vi.spyOn(Object.getPrototypeOf(sessionStorage), "removeItem").mockImplementation(() => { throw new Error("Storage unavailable"); });
|
||||
await act(() => findButton(container, "Discard saved delivery record").click());
|
||||
await flushReact();
|
||||
await act(() => findButton(document.body, "Discard record").click());
|
||||
await flushReact();
|
||||
expect(sessionStorage.getItem(key)).toBe(saved);
|
||||
expect(container.querySelector("textarea")?.disabled).toBe(true);
|
||||
expect(container.textContent).toContain("could not be discarded");
|
||||
expect(mockChatEndpointsApi.publishBoardMessage).not.toHaveBeenCalled();
|
||||
remove.mockRestore();
|
||||
}
|
||||
await act(() => findButton(container, "Discard saved delivery record").click());
|
||||
await flushReact();
|
||||
await act(() => findButton(document.body, "Discard record").click());
|
||||
await flushReact();
|
||||
expect(sessionStorage.getItem(key)).toBeNull();
|
||||
await act(() => findButton(container, "Send to channel").click());
|
||||
await flushReact();
|
||||
expect(container.querySelector("textarea")?.disabled).toBe(false);
|
||||
expect(container.querySelector("textarea")?.value).toBe("");
|
||||
expect(mockChatEndpointsApi.publishBoardMessage).not.toHaveBeenCalled();
|
||||
await act(() => setTextareaValue(container.querySelector("textarea")!, "Fresh deliberate message"));
|
||||
mockChatEndpointsApi.publishBoardMessage.mockResolvedValueOnce({ id: "fresh-publication", state: "published", attempts: 1 });
|
||||
await act(() => [...container.querySelectorAll("button")].filter(button => button.textContent?.trim() === "Send to channel").at(-1)!.click());
|
||||
await flushReact();
|
||||
expect(mockChatEndpointsApi.publishBoardMessage).toHaveBeenCalledWith("endpoint-1", "conversation-1", "Fresh deliberate message", expect.not.stringMatching("legacy-request-key-1"), []);
|
||||
});
|
||||
|
||||
it("asks for Resend now once automatic resends stop", async () => {
|
||||
autoResendOverride.stalled = true;
|
||||
mockChatEndpointsApi.publishBoardMessage.mockRejectedValue(
|
||||
new ApiError("Bad gateway", 502, { error: "Bad gateway" }),
|
||||
);
|
||||
await renderBanner();
|
||||
await composeAndSubmit();
|
||||
expect(container.textContent).toContain("Couldn’t send this update yet.");
|
||||
expect(container.textContent).toContain("Automatic resends stopped; choose Resend now");
|
||||
expect(container.textContent).not.toContain("It resends on its own");
|
||||
expect(findButton(container, "Resend now")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("only reports success and clears the draft after confirmed publication", async () => {
|
||||
@@ -322,6 +467,7 @@ describe("ExternallyConnectedTaskBanner publication truth", () => {
|
||||
"issue-1",
|
||||
"endpoint-1",
|
||||
"conversation-1",
|
||||
"user-1",
|
||||
),
|
||||
)?.attachmentNames,
|
||||
).toEqual([{ id: "uploaded-image", name: "cat.png" }]);
|
||||
@@ -879,7 +1025,12 @@ describe("ExternallyConnectedTaskBanner publication truth", () => {
|
||||
?.click(),
|
||||
);
|
||||
await flushReact();
|
||||
expect(container.textContent).toContain("Delivery result not confirmed");
|
||||
// A 5xx may have committed: it waits to resend with the same identity.
|
||||
expect(container.textContent).toContain(
|
||||
kind === "server-error"
|
||||
? "Sending when reconnected…"
|
||||
: "Delivery result not confirmed",
|
||||
);
|
||||
expect(container.textContent).not.toContain("Edit rejected send");
|
||||
expect(container.querySelector("textarea")?.disabled).toBe(true);
|
||||
expect(mockChatEndpointsApi.publishBoardMessage).toHaveBeenCalledTimes(1);
|
||||
@@ -1068,6 +1219,7 @@ describe("ExternallyConnectedTaskBanner publication truth", () => {
|
||||
"issue-1",
|
||||
"endpoint-1",
|
||||
"conversation-1",
|
||||
"user-1",
|
||||
),
|
||||
),
|
||||
).toBeNull();
|
||||
@@ -1217,6 +1369,7 @@ describe("ExternallyConnectedTaskBanner publication truth", () => {
|
||||
"issue-1",
|
||||
"endpoint-1",
|
||||
"conversation-1",
|
||||
"user-1",
|
||||
),
|
||||
),
|
||||
).toMatchObject({
|
||||
@@ -1231,6 +1384,7 @@ describe("ExternallyConnectedTaskBanner publication truth", () => {
|
||||
"issue-1",
|
||||
"endpoint-2",
|
||||
"conversation-2",
|
||||
"user-1",
|
||||
),
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TaskVoiceLauncher } from "@/components/voice/TaskVoiceLauncher";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ExternalLink, Paperclip, Radio } from "lucide-react";
|
||||
import type {
|
||||
ChatPublicationState,
|
||||
@@ -22,6 +22,11 @@ import { cn } from "@/lib/utils";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useChatConnectorsEnabled, chatProviderVisible } from "@/hooks/useChatConnectorsEnabled";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { describeError } from "@/api/errors";
|
||||
import { classifySendFailure, type SendOutcome } from "@/lib/pending-send";
|
||||
import { useAutoResend } from "@/hooks/useDurableSubmit";
|
||||
import { useDraftOwner, useDraftOwnerReader } from "@/hooks/useDraftOwner";
|
||||
import { useAccountBoundMutation } from "@/hooks/useAccountBoundMutation";
|
||||
import {
|
||||
boardSendDraftKey,
|
||||
clearBoardSendDraft,
|
||||
@@ -33,6 +38,8 @@ import {
|
||||
type RetainedBoardSend,
|
||||
} from "./board-send-draft";
|
||||
|
||||
import { LegacyBoardSendNotice } from "./LegacyBoardSendNotice";
|
||||
|
||||
const providerNames: Record<ChatProvider, string> = {
|
||||
speko: "Speko",
|
||||
slack: "Slack",
|
||||
@@ -136,11 +143,13 @@ type ConnectedTaskProps = {
|
||||
|
||||
export function ExternallyConnectedTaskBanner(props: ConnectedTaskProps) {
|
||||
const { binding } = useIssueChatBinding(props.companyId, props.issueId);
|
||||
const draftOwner = useDraftOwner();
|
||||
return <>
|
||||
{binding && binding.provider !== "speko" && binding.provider !== "agentmail" && <ConnectedTaskComposer
|
||||
key={boardSendDraftKey(props.companyId, props.issueId, binding.endpointId, binding.conversationId)}
|
||||
{draftOwner !== undefined && binding && binding.provider !== "speko" && binding.provider !== "agentmail" && <ConnectedTaskComposer
|
||||
key={boardSendDraftKey(props.companyId, props.issueId, binding.endpointId, binding.conversationId, draftOwner)}
|
||||
{...props}
|
||||
binding={binding}
|
||||
draftOwner={draftOwner}
|
||||
/>}
|
||||
<TaskVoiceLauncher companyId={props.companyId} issueId={props.issueId} agentId={props.assigneeAgentId} boundEndpointId={binding?.provider === "speko" ? binding.endpointId : undefined} />
|
||||
</>;
|
||||
@@ -153,7 +162,9 @@ function ConnectedTaskComposer({
|
||||
issueId,
|
||||
issueCacheRefs,
|
||||
binding,
|
||||
}: ConnectedTaskProps & { binding: ExternalChannelBindingSummary }) {
|
||||
draftOwner,
|
||||
}: ConnectedTaskProps & { binding: ExternalChannelBindingSummary; draftOwner: string }) {
|
||||
const readOwner = useDraftOwnerReader();
|
||||
const { pushToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [composing, setComposing] = useState(false);
|
||||
@@ -174,6 +185,7 @@ function ConnectedTaskComposer({
|
||||
);
|
||||
const [selectionNotice, setSelectionNotice] = useState(false);
|
||||
const [storageError, setStorageError] = useState<string | null>(null);
|
||||
const [unownedRequest, setUnownedRequest] = useState(false);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const uploadInFlight = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
@@ -194,13 +206,21 @@ function ConnectedTaskComposer({
|
||||
issueId,
|
||||
binding.endpointId,
|
||||
binding.conversationId,
|
||||
draftOwner,
|
||||
)
|
||||
: null;
|
||||
const legacyStorageKey = boardSendDraftKey(companyId, issueId, binding.endpointId, binding.conversationId);
|
||||
const loadedStorageKey = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!storageKey || loadedStorageKey.current === storageKey) return;
|
||||
loadedStorageKey.current = storageKey;
|
||||
try {
|
||||
if (sessionStorage.getItem(legacyStorageKey) !== null) {
|
||||
setUnownedRequest(true);
|
||||
setStorageError("An earlier update has an unknown sender. Open Activity to check its delivery before starting a new update.");
|
||||
setComposing(true);
|
||||
return;
|
||||
}
|
||||
const saved = readBoardSendDraft(storageKey);
|
||||
retainedScopeKey.current = storageKey;
|
||||
retainedSend.current = saved;
|
||||
@@ -220,7 +240,7 @@ function ConnectedTaskComposer({
|
||||
);
|
||||
setComposing(true);
|
||||
}
|
||||
}, [storageKey]);
|
||||
}, [legacyStorageKey, storageKey, unownedRequest]);
|
||||
const deliveryScopeReady = Boolean(
|
||||
storageKey &&
|
||||
loadedStorageKey.current === storageKey &&
|
||||
@@ -265,6 +285,7 @@ function ConnectedTaskComposer({
|
||||
const publicationStatus = useQuery({
|
||||
queryKey: [
|
||||
"chat-publication-batch",
|
||||
draftOwner,
|
||||
companyId,
|
||||
binding?.endpointId,
|
||||
binding?.conversationId,
|
||||
@@ -294,21 +315,23 @@ function ConnectedTaskComposer({
|
||||
finishPublication();
|
||||
}
|
||||
}, [publication, publicationStatus.data, finishPublication]);
|
||||
const publish = useMutation({
|
||||
const publish = useAccountBoundMutation({
|
||||
mutationFn: (input: {
|
||||
attachmentIds: string[];
|
||||
body: string;
|
||||
idempotencyKey: string;
|
||||
endpointId: string;
|
||||
conversationId: string;
|
||||
}) =>
|
||||
chatEndpointsApi.publishBoardMessage(
|
||||
}) => {
|
||||
if (readOwner() !== draftOwner) throw new DOMException("Your account changed. Reopen this task to send from your current account.", "AbortError");
|
||||
return chatEndpointsApi.publishBoardMessage(
|
||||
input.endpointId,
|
||||
input.conversationId,
|
||||
input.body,
|
||||
input.idempotencyKey,
|
||||
input.attachmentIds,
|
||||
),
|
||||
);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
invalidateTask();
|
||||
const feedback = publicationFeedback[result.state];
|
||||
@@ -366,16 +389,50 @@ function ConnectedTaskComposer({
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Outages and lost receipts resend on their own with the same request
|
||||
// identity; the inline notice covers them.
|
||||
if (classifySendFailure(error) === "pending") return;
|
||||
pushToast({
|
||||
title: "Couldn't confirm channel delivery",
|
||||
body:
|
||||
error instanceof Error
|
||||
? `${error.message} Your draft is kept; retrying here reuses the same request identity.`
|
||||
: "Your draft is kept; retrying here reuses the same request identity.",
|
||||
body: `${describeError(error).body} Your draft is kept; retrying here reuses the same request identity.`,
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
// A send that failed with an outage or a lost receipt in this session
|
||||
// resends with its saved key; the server returns the original publication
|
||||
// instead of queueing another message. A send restored after reload has an
|
||||
// unknown outcome and waits for an explicit retry.
|
||||
const resendWaiting = Boolean(
|
||||
deliveryScopeReady &&
|
||||
unconfirmedRequest &&
|
||||
retainedSend.current &&
|
||||
!rejection &&
|
||||
!publication &&
|
||||
!storageError &&
|
||||
publish.status === "error" &&
|
||||
classifySendFailure(publish.error) === "pending",
|
||||
);
|
||||
const autoResend = useAutoResend({
|
||||
sourceId: `board-send:${storageKey ?? issueId}`,
|
||||
active: resendWaiting,
|
||||
resend: async (): Promise<SendOutcome> => {
|
||||
const input = retainedSend.current;
|
||||
if (!input || readOwner() !== draftOwner) return "rejected";
|
||||
try {
|
||||
await publish.mutateAsync({
|
||||
...input,
|
||||
endpointId: binding.endpointId,
|
||||
conversationId: binding.conversationId,
|
||||
});
|
||||
return "sent";
|
||||
} catch (error) {
|
||||
return classifySendFailure(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
// Automatic resends gave up while online; only Resend now tries again.
|
||||
const resendStalled = resendWaiting && autoResend.stalled && !autoResend.resending;
|
||||
const uploadDisabled = Boolean(
|
||||
retainedSend.current ||
|
||||
publication ||
|
||||
@@ -653,9 +710,21 @@ function ConnectedTaskComposer({
|
||||
)}
|
||||
{storageError && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{storageError}
|
||||
{storageError}{" "}
|
||||
<Link className="underline underline-offset-4" to={activityPath}>Open Activity</Link>
|
||||
</p>
|
||||
)}
|
||||
{unownedRequest && <LegacyBoardSendNotice onDiscard={() => {
|
||||
if (readOwner() !== draftOwner) return;
|
||||
try { clearBoardSendDraft(legacyStorageKey); }
|
||||
catch {
|
||||
setStorageError("Saved delivery record could not be discarded. Restore browser storage, then try again.");
|
||||
return;
|
||||
}
|
||||
loadedStorageKey.current = null;
|
||||
setUnownedRequest(false);
|
||||
setStorageError(null);
|
||||
}} />}
|
||||
{rejection && (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -706,17 +775,26 @@ function ConnectedTaskComposer({
|
||||
)}
|
||||
{!rejection &&
|
||||
(publish.isError || unconfirmedRequest) &&
|
||||
!publish.isPending &&
|
||||
// An automatic resend keeps the waiting notice up while it runs.
|
||||
(!publish.isPending || autoResend.resending) &&
|
||||
!publication && (
|
||||
<div
|
||||
role="alert"
|
||||
className="space-y-1 rounded-md border border-border bg-background p-3 text-xs"
|
||||
>
|
||||
<p className="font-medium">Delivery result not confirmed</p>
|
||||
<p className="font-medium">
|
||||
{resendStalled
|
||||
? "Couldn’t send this update yet."
|
||||
: resendWaiting || autoResend.resending
|
||||
? "Sending when reconnected…"
|
||||
: "Delivery result not confirmed"}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Your exact draft and request identity are kept. Retry safely
|
||||
to learn the authoritative publication state without creating
|
||||
a duplicate.
|
||||
{resendStalled
|
||||
? "Your exact draft and request identity are kept. Automatic resends stopped; choose Resend now to try again without creating a duplicate."
|
||||
: resendWaiting || autoResend.resending
|
||||
? "Your exact draft and request identity are kept. It resends on its own without creating a duplicate."
|
||||
: "Your exact draft and request identity are kept. Retry safely to learn the authoritative publication state without creating a duplicate."}
|
||||
</p>
|
||||
<Link
|
||||
className="inline-block font-medium underline underline-offset-4"
|
||||
@@ -845,11 +923,17 @@ function ConnectedTaskComposer({
|
||||
onClick={() => {
|
||||
if (
|
||||
uploadInFlight.current ||
|
||||
readOwner() !== draftOwner ||
|
||||
!storageKey ||
|
||||
loadedStorageKey.current !== storageKey ||
|
||||
retainedScopeKey.current !== storageKey
|
||||
)
|
||||
return;
|
||||
if (resendWaiting) {
|
||||
// Same saved request; also restarts the automatic resends.
|
||||
autoResend.resendNow();
|
||||
return;
|
||||
}
|
||||
idempotencyKey.current ??= crypto.randomUUID();
|
||||
const input = retainedSend.current ?? {
|
||||
attachmentIds: selectedAttachmentIds,
|
||||
@@ -883,7 +967,9 @@ function ConnectedTaskComposer({
|
||||
>
|
||||
{publish.isPending
|
||||
? "Sending…"
|
||||
: !rejection && (publish.isError || unconfirmedRequest)
|
||||
: resendWaiting
|
||||
? "Resend now"
|
||||
: !rejection && (publish.isError || unconfirmedRequest)
|
||||
? "Retry safely"
|
||||
: "Send to channel"}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function LegacyBoardSendNotice({ onDiscard }: { onDiscard: () => void }) {
|
||||
return <AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">Discard saved delivery record</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Discard saved delivery record?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
First check Activity. Discarding this browser record does not cancel an update
|
||||
that was already accepted. It lets you start a new update.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep record</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onDiscard}>Discard record</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>;
|
||||
}
|
||||
@@ -75,6 +75,18 @@ describe("session-scoped Board send identity", () => {
|
||||
clearBoardSendDraft(key);
|
||||
expect(readBoardSendDraft(key)).toBeNull();
|
||||
});
|
||||
it("keeps account-scoped sends separate from each other and unowned legacy records", () => {
|
||||
const scope = ["company", "task", "endpoint", "conversation"] as const;
|
||||
const a = boardSendDraftKey(...scope, "user-a");
|
||||
const b = boardSendDraftKey(...scope, "user-b");
|
||||
const legacy = boardSendDraftKey(...scope);
|
||||
const draft = { body: "Private A", attachmentIds: [], idempotencyKey: "stable-request-key-a", publication: null };
|
||||
writeBoardSendDraft(a, draft);
|
||||
expect(readBoardSendDraft(a)).toEqual(draft);
|
||||
expect(readBoardSendDraft(b)).toBeNull();
|
||||
expect(readBoardSendDraft(legacy)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects corrupt retained identity instead of silently enabling a new send", () => {
|
||||
sessionStorage.setItem("corrupt", '{"body":"draft"}');
|
||||
expect(() => readBoardSendDraft("corrupt")).toThrow(
|
||||
|
||||
@@ -71,8 +71,12 @@ export function boardSendDraftKey(
|
||||
issueId: string,
|
||||
endpointId: string,
|
||||
conversationId: string,
|
||||
owner?: string,
|
||||
) {
|
||||
return `paperclip:board-send:v1:${JSON.stringify([companyId, issueId, endpointId, conversationId])}`;
|
||||
const scope = [companyId, issueId, endpointId, conversationId];
|
||||
return owner === undefined
|
||||
? `paperclip:board-send:v1:${JSON.stringify(scope)}`
|
||||
: `paperclip:board-send:v2:${JSON.stringify([owner, ...scope])}`;
|
||||
}
|
||||
|
||||
export function readBoardSendDraft(key: string): RetainedBoardSend | null {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, type ReactElement } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueChatThread } from "./IssueChatThread";
|
||||
import { TaskChatComposer } from "./task-chat/TaskChatComposer";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
loadDraftSubmission,
|
||||
saveDraft,
|
||||
saveDraftAttachments,
|
||||
saveDraftSubmission,
|
||||
} from "../lib/composer-draft";
|
||||
|
||||
vi.mock("./MarkdownEditor", async () => {
|
||||
const { forwardRef, useImperativeHandle, useRef } = await import("react");
|
||||
return {
|
||||
MarkdownEditor: forwardRef(function Editor(
|
||||
{ value, onChange, readOnly }: { value: string; onChange: (value: string) => void; readOnly?: boolean },
|
||||
ref,
|
||||
) {
|
||||
const input = useRef<HTMLTextAreaElement>(null);
|
||||
useImperativeHandle(ref, () => ({ focus: () => input.current?.focus(), clear: () => onChange("") }));
|
||||
return <textarea ref={input} aria-label="Recovery draft" value={value} readOnly={readOnly}
|
||||
onChange={event => onChange(event.target.value)} />;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let client: QueryClient;
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
client = new QueryClient({ defaultOptions: { queries: { enabled: false, retry: false } } });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
client.clear();
|
||||
});
|
||||
|
||||
describe.each(["task chat", "legacy thread"] as const)("%s composer recovery", surface => {
|
||||
it.each([false, true])("can send the retained draft after an already-reviewed unknown save (reload=%s)", async reload => {
|
||||
const key = "reviewed-unknown-save";
|
||||
const originalAttemptId = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
|
||||
const attachmentId = "5e5f9946-c706-4785-8988-d4d6f0f499ab";
|
||||
const body = "I enabled the connection. Please try again.";
|
||||
const contentPath = `/api/attachments/${attachmentId}/content`;
|
||||
saveDraft(key, body);
|
||||
saveDraftAttachments(key, [{ attachmentId, name: "notes.txt", inline: false, contentPath }]);
|
||||
// This is the old persisted state shown after Review conversation. It has
|
||||
// no replayable request snapshot, so recovery must not blindly resend it.
|
||||
saveDraftSubmission(key, { attemptId: originalAttemptId, reviewed: true });
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
const composer: ReactElement = surface === "task chat"
|
||||
? <TaskChatComposer onAdd={onAdd} draftKey={key} workMode="standard" />
|
||||
: <MemoryRouter><IssueChatThread onAdd={onAdd} draftKey={key}
|
||||
comments={[]} linkedRuns={[]} timelineEvents={[]} liveRuns={[]}
|
||||
enableLiveTranscriptPolling={false} /></MemoryRouter>;
|
||||
const element = <QueryClientProvider client={client}>{composer}</QueryClientProvider>;
|
||||
await act(async () => root.render(element));
|
||||
if (reload) {
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
await act(async () => root.render(element));
|
||||
}
|
||||
const editor = () => container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Recovery draft"]')!;
|
||||
const send = () => container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-send"]')
|
||||
?? Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Send")!;
|
||||
expect(editor().value).toBe(body);
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
const recover = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Cancel");
|
||||
expect(recover, "An unknown save must offer a non-destructive way back to the composer").toBeDefined();
|
||||
await act(async () => recover!.click());
|
||||
expect(editor().readOnly).toBe(false);
|
||||
expect(editor().value).toBe(body);
|
||||
expect(container.textContent).toContain("notes.txt");
|
||||
expect(loadDraftSubmission(key)).toBeNull();
|
||||
expect(send().disabled).toBe(false);
|
||||
await act(async () => send().click());
|
||||
expect(onAdd).toHaveBeenCalledExactlyOnceWith(`${body}\n\n[notes.txt](${contentPath})`,
|
||||
undefined, undefined, [attachmentId], expect.any(String));
|
||||
expect(onAdd.mock.calls[0]![4]).not.toBe(originalAttemptId);
|
||||
expect(editor().value).toBe("");
|
||||
expect(container.textContent).not.toContain("notes.txt");
|
||||
// Recovery must leave ordinary sending usable, including after a reload.
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!.call(editor(), "One more message");
|
||||
editor().dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
expect(send().disabled).toBe(false);
|
||||
await act(async () => send().click());
|
||||
expect(onAdd.mock.calls[1]![0]).toBe("One more message");
|
||||
expect(onAdd.mock.calls[1]![4]).not.toBe(onAdd.mock.calls[0]![4]);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,15 @@ import {
|
||||
saveDraftSubmission,
|
||||
} from "../../lib/composer-draft";
|
||||
import { CommentSubmissionUnknownError } from "../../lib/comment-submit-result";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryKeys } from "../../lib/queryKeys";
|
||||
import { useAccountBoundMutation } from "../../hooks/useAccountBoundMutation";
|
||||
|
||||
// The session lookup never answers on its own; tests set the account directly.
|
||||
vi.mock("../../api/auth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../api/auth")>();
|
||||
return { ...actual, authApi: { ...actual.authApi, getSession: () => new Promise(() => {}) } };
|
||||
});
|
||||
|
||||
/**
|
||||
* MDXEditor-in-jsdom weight (mirrors MarkdownEditor.test.tsx): the real editor
|
||||
@@ -365,10 +374,36 @@ describe("TaskChatComposer", () => {
|
||||
expect(loadDraftSubmission(key)).toBeNull();
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} />);
|
||||
expect(container.textContent).not.toContain("couldn’t confirm");
|
||||
expect(container.textContent).not.toContain("Sending when reconnected…");
|
||||
expect(editable().textContent).toBe("");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ reassignment: { assigneeAgentId: "old-agent", assigneeUserId: null } },
|
||||
{ runSettings: { model: "old-model" } },
|
||||
{ reopen: true },
|
||||
])("keeps an uncertain composite send for reconciliation without resending task changes: %j", async (changes) => {
|
||||
const key = "uncertain-composite";
|
||||
const attemptId = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
|
||||
saveDraft(key, "Keep the comment");
|
||||
saveDraftSubmission(key, { attemptId, reviewed: false, request: { body: "Keep the comment", ...changes }, requestOwner: "user-1" });
|
||||
const onAdd = vi.fn();
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
render(<QueryClientProvider client={client}><TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} /></QueryClientProvider>);
|
||||
await flushAsync();
|
||||
expect(container.textContent).toContain("We couldn’t confirm whether this message was sent.");
|
||||
expect(container.textContent).not.toContain("Resend now");
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
const cancel = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Cancel")!;
|
||||
flushSync(() => cancel.click());
|
||||
await flushAsync();
|
||||
expect(editable().textContent).toBe("Keep the comment");
|
||||
expect(loadDraftSubmission(key)).toBeNull();
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
client.clear();
|
||||
});
|
||||
|
||||
it("automatically reconciles a restored submission by its server receipt, not its text", async () => {
|
||||
const key = "saved-receipt";
|
||||
const attemptId = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
|
||||
@@ -386,6 +421,87 @@ describe("TaskChatComposer", () => {
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the original key after account cancellation of an accepted initial send", async () => {
|
||||
const key = "accepted-account-roundtrip";
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
let accepted!: () => void;
|
||||
const post = vi.fn<(body: string, requestId?: string) => Promise<void>>()
|
||||
.mockImplementationOnce(() => new Promise<void>(resolve => { accepted = resolve; })).mockResolvedValue(undefined);
|
||||
function OwnedComposer() {
|
||||
const mutation = useAccountBoundMutation({ mutationFn: ({ body, requestId }: { body: string; requestId?: string }) => post(body, requestId) });
|
||||
return <TaskChatComposer draftKey={key} workMode="standard"
|
||||
onAdd={(body, _reopen, _reassign, _files, requestId) => mutation.mutateAsync({ body, requestId })} />;
|
||||
}
|
||||
render(<QueryClientProvider client={client}><OwnedComposer /></QueryClientProvider>);
|
||||
typeText("Accepted before the account changed");
|
||||
pressKey("Enter", { metaKey: true });
|
||||
await flushAsync();
|
||||
const requestId = post.mock.calls[0][1]!;
|
||||
flushSync(() => {
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
});
|
||||
accepted();
|
||||
await flushAsync();
|
||||
expect(loadDraftSubmission(key)?.attemptId).toBe(requestId);
|
||||
expect(editable().textContent).toBe("Accepted before the account changed");
|
||||
const retry = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Resend now")!;
|
||||
flushSync(() => retry.click());
|
||||
await flushAsync();
|
||||
expect(post).toHaveBeenCalledTimes(2);
|
||||
expect(post.mock.calls[1][1]).toBe(requestId);
|
||||
expect(loadDraftSubmission(key)).toBeNull();
|
||||
client.clear();
|
||||
});
|
||||
|
||||
it.each([false, true])("does not retry from a stale account render (returns to original=%s)", async (returns) => {
|
||||
const key = "stale-account-retry";
|
||||
const attemptId = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
|
||||
saveDraft(key, "Original account text");
|
||||
saveDraftSubmission(key, { attemptId, reviewed: false, request: { body: "Original account text" }, requestOwner: "user-1" });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
render(<QueryClientProvider client={client}><TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} /></QueryClientProvider>);
|
||||
await flushAsync();
|
||||
const resend = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Resend now")!;
|
||||
flushSync(() => {
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } });
|
||||
if (returns) client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
resend.click();
|
||||
});
|
||||
await flushAsync();
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
expect(loadDraftSubmission(key)?.attemptId).toBe(attemptId);
|
||||
client.clear();
|
||||
});
|
||||
|
||||
it.each(["success", "rejection"])("settles a receipt before its in-flight resend %s without cutting the next draft", async (outcome) => {
|
||||
const key = "receipt-and-resend";
|
||||
const attemptId = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
|
||||
saveDraft(key, "First request\n\nKeep this next draft");
|
||||
saveDraftSubmission(key, { attemptId, reviewed: false, nextDraftOffset: 15, request: { body: "First request" }, requestOwner: "user-1" });
|
||||
let finish!: () => void;
|
||||
let fail!: (error: Error) => void;
|
||||
const onAdd = vi.fn(() => new Promise<void>((resolve, reject) => { finish = resolve; fail = reject; }));
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
const show = (confirmedSubmissionIds = new Set<string>()) => render(<QueryClientProvider client={client}><TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} confirmedSubmissionIds={confirmedSubmissionIds} /></QueryClientProvider>);
|
||||
show(); await flushAsync();
|
||||
const resend = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Resend now")!;
|
||||
flushSync(() => resend.click()); await flushAsync();
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
show(new Set([attemptId])); await flushAsync();
|
||||
expect(editable().textContent).toBe("Keep this next draft");
|
||||
if (outcome === "success") finish(); else fail(new Error("Late rejected resend"));
|
||||
await flushAsync();
|
||||
expect(container.textContent).not.toContain("Late rejected resend");
|
||||
expect(editable().textContent).toBe("Keep this next draft");
|
||||
expect(localStorage.getItem(key)).toBe("Keep this next draft");
|
||||
client.clear();
|
||||
});
|
||||
|
||||
it("preserves the next draft when an uncertain submission is later confirmed after reload", async () => {
|
||||
const key = "uncertain-with-next-draft";
|
||||
let rejectSend!: (error: Error) => void;
|
||||
@@ -493,7 +609,7 @@ describe("TaskChatComposer", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("fences an unknown save in memory with disabled storage until a successful explicit review and local discard", async () => {
|
||||
it("keeps an unconfirmed send in memory with disabled storage and resends it with the same request ID", async () => {
|
||||
const storage = vi
|
||||
.spyOn(Storage.prototype, "setItem")
|
||||
.mockImplementation(() => {
|
||||
@@ -501,55 +617,147 @@ describe("TaskChatComposer", () => {
|
||||
});
|
||||
const onAdd = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new CommentSubmissionUnknownError());
|
||||
const review = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("Refresh unavailable"))
|
||||
.mockRejectedValueOnce(new CommentSubmissionUnknownError())
|
||||
.mockResolvedValue(undefined);
|
||||
try {
|
||||
renderComposer({
|
||||
onAdd: onAdd,
|
||||
workMode: "standard",
|
||||
draftKey: "storage-disabled",
|
||||
onReviewConversation: review,
|
||||
});
|
||||
typeText("Do not replay this unknown save");
|
||||
typeText("Send this exactly once");
|
||||
pressKey("Enter", { metaKey: true });
|
||||
await flushAsync();
|
||||
await flushAsync();
|
||||
expect(sendButton().disabled).toBe(true);
|
||||
expect(container.textContent).toContain(
|
||||
"couldn’t confirm whether this comment was saved",
|
||||
);
|
||||
expect(container.textContent).toContain("Sending when reconnected…");
|
||||
expect(container.textContent).not.toContain("Discard draft");
|
||||
pressKey("Enter", { metaKey: true });
|
||||
await flushAsync();
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
const reviewButton = () =>
|
||||
Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Review conversation",
|
||||
)!;
|
||||
flushSync(() => reviewButton().click());
|
||||
await flushAsync();
|
||||
expect(container.textContent).toContain(
|
||||
"Couldn’t refresh the conversation",
|
||||
);
|
||||
expect(container.textContent).not.toContain(
|
||||
"Discard draft and start new",
|
||||
);
|
||||
flushSync(() => reviewButton().click());
|
||||
await flushAsync();
|
||||
expect(review).toHaveBeenCalledTimes(2);
|
||||
const discard = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Discard draft and start new",
|
||||
const resend = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Resend now",
|
||||
)!;
|
||||
flushSync(() => discard.click());
|
||||
flushSync(() => resend.click());
|
||||
await flushAsync();
|
||||
await flushAsync();
|
||||
expect(onAdd).toHaveBeenCalledTimes(2);
|
||||
// Same body and same client request ID, so the server dedupes.
|
||||
expect(onAdd.mock.calls[1]?.[0]).toBe(onAdd.mock.calls[0]?.[0]);
|
||||
expect(onAdd.mock.calls[1]?.[4]).toBe(onAdd.mock.calls[0]?.[4]);
|
||||
expect(container.textContent).not.toContain("Sending when reconnected…");
|
||||
expect(editable().textContent).toBe("");
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
storage.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("gives an unconfirmed send back to the editor on Cancel without resending it", async () => {
|
||||
const onAdd = vi.fn().mockRejectedValue(new CommentSubmissionUnknownError());
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey="cancel-pending" />);
|
||||
typeText("Keep my words");
|
||||
pressKey("Enter", { metaKey: true });
|
||||
await flushAsync();
|
||||
await flushAsync();
|
||||
expect(loadDraftSubmission("cancel-pending")?.request?.body).toBe("Keep my words");
|
||||
const cancel = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Cancel",
|
||||
)!;
|
||||
flushSync(() => cancel.click());
|
||||
await flushAsync();
|
||||
expect(container.textContent).not.toContain("Sending when reconnected…");
|
||||
expect(editable().textContent).toBe("Keep my words");
|
||||
expect(loadDraftSubmission("cancel-pending")).toBeNull();
|
||||
expect(sendButton().disabled).toBe(false);
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("puts the text back with readable copy when a resend is rejected", async () => {
|
||||
const { ApiError } = await import("../../api/client");
|
||||
const onAdd = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new CommentSubmissionUnknownError())
|
||||
.mockRejectedValueOnce(new ApiError("Forbidden", 403, { error: "Forbidden" }));
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey="rejected-resend" />);
|
||||
typeText("Rejected later");
|
||||
pressKey("Enter", { metaKey: true });
|
||||
await flushAsync();
|
||||
await flushAsync();
|
||||
const resend = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Resend now",
|
||||
)!;
|
||||
flushSync(() => resend.click());
|
||||
await flushAsync();
|
||||
await flushAsync();
|
||||
expect(onAdd).toHaveBeenCalledTimes(2);
|
||||
expect(container.textContent).not.toContain("Sending when reconnected…");
|
||||
expect(editable().textContent).toBe("Rejected later");
|
||||
expect(container.textContent).toContain("Forbidden");
|
||||
expect(loadDraftSubmission("rejected-resend")).toBeNull();
|
||||
});
|
||||
|
||||
it("resends a comment sent before the session loaded once the account is known", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const onAdd = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new CommentSubmissionUnknownError())
|
||||
.mockResolvedValue(undefined);
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey="owner-late" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
typeText("Sent before the session");
|
||||
pressKey("Enter", { metaKey: true });
|
||||
await flushAsync();
|
||||
await flushAsync();
|
||||
// Until the account is known the send cannot be replayed.
|
||||
const resendButton = () => Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Resend now",
|
||||
);
|
||||
expect(resendButton()).toBeUndefined();
|
||||
|
||||
act(() => {
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
});
|
||||
await flushAsync();
|
||||
await flushAsync();
|
||||
expect(loadDraftSubmission("owner-late")?.requestOwner).toBe("user-1");
|
||||
expect(container.textContent).toContain("Sending when reconnected…");
|
||||
flushSync(() => resendButton()!.click());
|
||||
await flushAsync();
|
||||
await flushAsync();
|
||||
expect(onAdd).toHaveBeenCalledTimes(2);
|
||||
expect(onAdd.mock.calls[1]?.[4]).toBe(onAdd.mock.calls[0]?.[4]);
|
||||
expect(container.textContent).not.toContain("Sending when reconnected…");
|
||||
client.clear();
|
||||
});
|
||||
|
||||
it("never binds an account to a stored send that has no owner", async () => {
|
||||
const key = "owner-unknown-stored";
|
||||
saveDraft(key, "Stored without owner");
|
||||
saveDraftSubmission(key, {
|
||||
attemptId: "baf8228f-0be7-45ae-a104-6fbe0af6f1d3",
|
||||
reviewed: false,
|
||||
request: { body: "Stored without owner" },
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
const onAdd = vi.fn();
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await flushAsync();
|
||||
expect(Array.from(container.querySelectorAll("button")).some(
|
||||
(button) => button.textContent === "Resend now",
|
||||
)).toBe(false);
|
||||
expect(loadDraftSubmission(key)?.requestOwner).toBeUndefined();
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
client.clear();
|
||||
});
|
||||
|
||||
it("restores an in-flight submission as uncertain only for its exact task key", async () => {
|
||||
const key = "uncertain-task-one";
|
||||
const attemptId = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
|
||||
@@ -608,6 +816,29 @@ describe("TaskChatComposer", () => {
|
||||
expect(onAttachImage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps a failed upload and retries it only when asked", async () => {
|
||||
const id = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
|
||||
const { ApiError } = await import("../../api/client");
|
||||
const onAttachImage = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new ApiError("Unavailable", 503, { error: "tenant_app_unavailable" }))
|
||||
.mockResolvedValue({ id, contentPath: `/api/attachments/${id}/content`, originalFilename: "notes.txt" });
|
||||
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
|
||||
pasteFiles([new File(["bytes"], "notes.txt", { type: "text/plain" })]);
|
||||
await flushAsync();
|
||||
expect(container.textContent).toContain("Paperclip is restarting or updating");
|
||||
expect(container.textContent).not.toContain("tenant_app_unavailable");
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(onAttachImage).toHaveBeenCalledTimes(1);
|
||||
const retry = container.querySelector<HTMLButtonElement>('button[aria-label="Retry notes.txt"]')!;
|
||||
flushSync(() => retry.click());
|
||||
await flushAsync();
|
||||
expect(onAttachImage).toHaveBeenCalledTimes(2);
|
||||
expect(container.querySelector('button[aria-label="Retry notes.txt"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain("Paperclip is restarting or updating");
|
||||
expect(sendButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("holds submission while an inline image upload is pending and binds its receipt", async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
const id = "5e5f9946-c706-4785-8988-d4d6f0f499ab";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCurrentAccountGuard } from "@/hooks/useAccountBoundMutation";
|
||||
import { AgentAvatar } from "../AgentAvatar";
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
@@ -18,14 +20,22 @@ import {
|
||||
saveDraft,
|
||||
saveDraftAttachments,
|
||||
loadDraftSubmission,
|
||||
replayableRequest,
|
||||
saveDraftSubmission,
|
||||
clearDraftSubmission,
|
||||
settleDraftSubmission,
|
||||
type ComposerDraftSubmission,
|
||||
type ComposerSendRequest,
|
||||
} from "@/lib/composer-draft";
|
||||
import { CommentSubmissionUnknownError } from "@/lib/comment-submit-result";
|
||||
import { classifySendFailure, type SendOutcome } from "@/lib/pending-send";
|
||||
import { describeError } from "@/api/errors";
|
||||
import { useAutoResend } from "@/hooks/useDurableSubmit";
|
||||
import { useDraftOwner } from "@/hooks/useDraftOwner";
|
||||
import { useRequestOwnerBinding } from "@/hooks/useRequestOwnerBinding";
|
||||
import { PendingSendNotice } from "@/components/PendingSendNotice";
|
||||
import {
|
||||
ArrowUp,
|
||||
RotateCw,
|
||||
Square,
|
||||
ChevronDown,
|
||||
CircleHelp,
|
||||
@@ -110,6 +120,8 @@ interface TaskChatComposerProps {
|
||||
details?: ReactNode;
|
||||
contextBar?: ReactNode;
|
||||
submitDisabled?: boolean;
|
||||
/** Lock the editor, e.g. while a submitted create waits to resend. */
|
||||
readOnly?: boolean;
|
||||
privacy?: { private: boolean; inherited?: string; onChange: (value: boolean) => void };
|
||||
onSelectFiles: (files: File[]) => void;
|
||||
runSettings: ComposerRunSettings | null;
|
||||
@@ -149,7 +161,6 @@ interface TaskChatComposerProps {
|
||||
mobile?: boolean;
|
||||
/** Storage key used to restore, persist, and clear this task's text draft. */
|
||||
draftKey?: string;
|
||||
onReviewConversation?: () => Promise<void>;
|
||||
/** When set, the main composer temporarily edits this queued message. */
|
||||
queuedEdit?: { commentId: string; body: string; stale?: boolean } | null;
|
||||
onSaveQueuedEdit?: (commentId: string, body: string) => Promise<void>;
|
||||
@@ -325,6 +336,11 @@ type ComposerAttachment = {
|
||||
error?: string;
|
||||
/** Set once uploaded; the submit path appends `[name](contentPath)` lines. */
|
||||
contentPath?: string;
|
||||
/**
|
||||
* The picked file, kept after a failed upload so the chip can offer Retry.
|
||||
* Uploads are not deduped by the server, so they never resend on their own.
|
||||
*/
|
||||
file?: File;
|
||||
};
|
||||
|
||||
type QueuedEditBackup = {
|
||||
@@ -403,7 +419,6 @@ export function TaskChatComposer({
|
||||
issueStatus,
|
||||
mobile = false,
|
||||
draftKey,
|
||||
onReviewConversation,
|
||||
queuedEdit = null,
|
||||
onSaveQueuedEdit,
|
||||
onCancelQueuedEdit,
|
||||
@@ -419,7 +434,6 @@ export function TaskChatComposer({
|
||||
const [localBody, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : ""));
|
||||
const body = creation ? creation.value : localBody;
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [reviewError, setReviewError] = useState(false);
|
||||
const [uncertainSubmission, setUncertainSubmission] =
|
||||
useState<ComposerDraftSubmission | null>(() =>
|
||||
draftKey ? loadDraftSubmission(draftKey) : null,
|
||||
@@ -482,12 +496,20 @@ export function TaskChatComposer({
|
||||
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||
const bodyRef = useRef(body);
|
||||
bodyRef.current = body;
|
||||
// Only the account that made a request may replay it.
|
||||
const draftOwner = useDraftOwner();
|
||||
const isCurrentAccount = useCurrentAccountGuard();
|
||||
const pendingDraftRef = useRef<{
|
||||
draftKey: string;
|
||||
attemptId: string;
|
||||
submittedBody: string;
|
||||
submittedAttachmentIds: string[];
|
||||
request: ComposerSendRequest;
|
||||
requestOwner?: string;
|
||||
} | null>(null);
|
||||
const markRequestUnbound = useRequestOwnerBinding({
|
||||
draftKey, owner: draftOwner, uncertainSubmission, setUncertainSubmission, pendingDraftRef,
|
||||
});
|
||||
function changeBody(value: string) {
|
||||
bodyRef.current = value;
|
||||
if (creation) {
|
||||
@@ -507,6 +529,8 @@ export function TaskChatComposer({
|
||||
attemptId: pending.attemptId, reviewed: false,
|
||||
nextDraftOffset: pending.submittedBody.length + (value ? 2 : 0),
|
||||
submittedAttachmentIds: pending.submittedAttachmentIds,
|
||||
request: pending.request,
|
||||
requestOwner: pending.requestOwner,
|
||||
});
|
||||
}
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -721,7 +745,8 @@ export function TaskChatComposer({
|
||||
? {
|
||||
...item,
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Upload failed",
|
||||
error: describeError(err, { action: "upload the file" }).body,
|
||||
file,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
@@ -776,7 +801,8 @@ export function TaskChatComposer({
|
||||
? {
|
||||
...item,
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Upload failed",
|
||||
error: describeError(err, { action: "upload the file" }).body,
|
||||
file,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
@@ -784,6 +810,14 @@ export function TaskChatComposer({
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry a failed upload from its kept file; only ever on an explicit click. */
|
||||
function retryUpload(attachment: ComposerAttachment) {
|
||||
const file = attachment.file;
|
||||
if (!file) return;
|
||||
setAttachments((prev) => prev.filter((item) => item.id !== attachment.id));
|
||||
void attachPickedFile(file);
|
||||
}
|
||||
|
||||
/** Images picked from the + button go inline at the caret, like paste/drop. */
|
||||
async function attachPickedFile(file: File) {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
@@ -895,7 +929,7 @@ export function TaskChatComposer({
|
||||
try {
|
||||
await creation.onSubmit(bodyRef.current, pendingMode, runSettings);
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : "Failed to create task. Try again.");
|
||||
setActionError(describeError(error, { action: "create the task" }).body);
|
||||
} finally {
|
||||
submittingRef.current = false;
|
||||
setSubmitting(false);
|
||||
@@ -1022,6 +1056,8 @@ export function TaskChatComposer({
|
||||
setBody("");
|
||||
setSubmitting(true);
|
||||
let attemptId: string | null = null;
|
||||
let request: ComposerSendRequest | null = null;
|
||||
const requestOwner = draftOwner;
|
||||
try {
|
||||
if (queuedEdit) {
|
||||
if (!onSaveQueuedEdit) return;
|
||||
@@ -1039,10 +1075,7 @@ export function TaskChatComposer({
|
||||
return;
|
||||
}
|
||||
attemptId = crypto.randomUUID();
|
||||
if (draftKey) {
|
||||
saveDraft(draftKey, submittedBody);
|
||||
saveDraftSubmission(draftKey, { attemptId, reviewed: false });
|
||||
}
|
||||
if (requestOwner === undefined) markRequestUnbound(attemptId);
|
||||
// IDs come only from this composer's upload receipts, never from parsing
|
||||
// arbitrary Markdown. A removed inline image no longer selects its receipt.
|
||||
const attachmentIds = [
|
||||
@@ -1059,8 +1092,19 @@ export function TaskChatComposer({
|
||||
.map((item) => item.attachmentId!),
|
||||
),
|
||||
];
|
||||
// The exact request is stored before it is sent, so a lost response or
|
||||
// an outage can resend it with the same ID instead of losing it.
|
||||
request = {
|
||||
body: fullBody,
|
||||
...(reopen === undefined ? {} : { reopen }),
|
||||
...(reassignment ? { reassignment } : {}),
|
||||
...(attachmentIds.length ? { attachmentIds } : {}),
|
||||
...(runSettings ? { runSettings: runSettings as unknown as Record<string, unknown> } : {}),
|
||||
};
|
||||
if (draftKey) {
|
||||
pendingDraftRef.current = { draftKey, attemptId, submittedBody, submittedAttachmentIds: attachmentIds };
|
||||
saveDraft(draftKey, submittedBody);
|
||||
saveDraftSubmission(draftKey, { attemptId, reviewed: false, request, requestOwner });
|
||||
pendingDraftRef.current = { draftKey, attemptId, submittedBody, submittedAttachmentIds: attachmentIds, request, requestOwner };
|
||||
changeBody(bodyRef.current);
|
||||
}
|
||||
if (runSettings) {
|
||||
@@ -1084,11 +1128,15 @@ export function TaskChatComposer({
|
||||
} catch (error) {
|
||||
if (mountedTaskKey.current !== draftKey) return;
|
||||
const nextDraft = bodyRef.current;
|
||||
if (attemptId && error instanceof CommentSubmissionUnknownError) {
|
||||
// Not delivered, or not known to be: keep it and resend with the same
|
||||
// ID when the connection allows. The server dedupes by that ID.
|
||||
if (attemptId && request && classifySendFailure(error) === "pending") {
|
||||
const uncertain = {
|
||||
attemptId, reviewed: false,
|
||||
nextDraftOffset: submittedBody.length + (nextDraft ? 2 : 0),
|
||||
submittedAttachmentIds: submittedAttachments.flatMap(item => item.attachmentId ? [item.attachmentId] : []),
|
||||
request,
|
||||
requestOwner,
|
||||
};
|
||||
setUncertainSubmission(uncertain);
|
||||
if (draftKey && loadDraftSubmission(draftKey)?.attemptId === attemptId)
|
||||
@@ -1113,45 +1161,77 @@ export function TaskChatComposer({
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!uncertainSubmission || !confirmedSubmissionIds?.has(uncertainSubmission.attemptId)) return;
|
||||
const nextDraft = uncertainSubmission.nextDraftOffset === undefined
|
||||
? "" : bodyRef.current.slice(uncertainSubmission.nextDraftOffset);
|
||||
if (draftKey) settleDraftSubmission(draftKey, uncertainSubmission.attemptId, nextDraft);
|
||||
/** A confirmed receipt for the retained send: drop its text, keep any next draft. */
|
||||
const settledSubmissionIds = useRef(new Set<string>());
|
||||
function settleUncertainSubmission(submission: ComposerDraftSubmission) {
|
||||
const settlementKey = JSON.stringify([draftKey, submission.attemptId]);
|
||||
if (settledSubmissionIds.current.has(settlementKey)) return;
|
||||
settledSubmissionIds.current.add(settlementKey);
|
||||
const nextDraft = submission.nextDraftOffset === undefined
|
||||
? "" : bodyRef.current.slice(submission.nextDraftOffset);
|
||||
if (draftKey) settleDraftSubmission(draftKey, submission.attemptId, nextDraft);
|
||||
setUncertainSubmission(null);
|
||||
setActionError(null);
|
||||
bodyRef.current = nextDraft;
|
||||
setBody(nextDraft);
|
||||
const submittedIds = uncertainSubmission.submittedAttachmentIds;
|
||||
const submittedIds = submission.submittedAttachmentIds;
|
||||
setAttachments(current => submittedIds
|
||||
? current.filter(item => !item.attachmentId || !submittedIds.includes(item.attachmentId))
|
||||
: []);
|
||||
}, [confirmedSubmissionIds, draftKey, uncertainSubmission]);
|
||||
|
||||
async function reviewUncertainSubmission() {
|
||||
if (!uncertainSubmission) return;
|
||||
setReviewError(false);
|
||||
try {
|
||||
if (!onReviewConversation) throw new Error("Review unavailable");
|
||||
await onReviewConversation();
|
||||
if (mountedTaskKey.current !== draftKey) return;
|
||||
const reviewed = { ...uncertainSubmission, reviewed: true };
|
||||
setUncertainSubmission(reviewed);
|
||||
if (
|
||||
draftKey &&
|
||||
loadDraftSubmission(draftKey)?.attemptId === reviewed.attemptId
|
||||
)
|
||||
saveDraftSubmission(draftKey, reviewed);
|
||||
} catch {
|
||||
setReviewError(true);
|
||||
}
|
||||
}
|
||||
|
||||
function discardUncertainDraft() {
|
||||
if (!uncertainSubmission?.reviewed) return;
|
||||
if (draftKey) clearDraft(draftKey, uncertainSubmission.attemptId);
|
||||
bodyRef.current = "";
|
||||
setBody("");
|
||||
setAttachments([]);
|
||||
useEffect(() => {
|
||||
if (!uncertainSubmission || !confirmedSubmissionIds?.has(uncertainSubmission.attemptId)) return;
|
||||
settleUncertainSubmission(uncertainSubmission);
|
||||
}, [confirmedSubmissionIds, draftKey, uncertainSubmission]);
|
||||
|
||||
async function resendUncertainSubmission(): Promise<SendOutcome> {
|
||||
if (!isCurrentAccount()) return "pending";
|
||||
const submission = uncertainSubmission;
|
||||
const request = replayableRequest(submission, draftOwner);
|
||||
if (!submission || !request || !onAdd) return "rejected";
|
||||
const taskKey = draftKey;
|
||||
const args = [
|
||||
request.body,
|
||||
request.reopen,
|
||||
request.reassignment,
|
||||
request.attachmentIds,
|
||||
submission.attemptId,
|
||||
] as const;
|
||||
try {
|
||||
if (request.runSettings) await onAdd(...args, request.runSettings as unknown as ComposerRunSettings);
|
||||
else await onAdd(...args);
|
||||
} catch (error) {
|
||||
if (!isCurrentAccount()) return "pending";
|
||||
const failure = classifySendFailure(error);
|
||||
if (settledSubmissionIds.current.has(JSON.stringify([taskKey, submission.attemptId]))) return failure;
|
||||
if (failure === "rejected") {
|
||||
// A definitive answer: the text is already back in the editor.
|
||||
if (taskKey) clearDraftSubmission(taskKey, submission.attemptId);
|
||||
if (mountedTaskKey.current === taskKey) {
|
||||
setUncertainSubmission(null);
|
||||
setActionError(describeError(error, { action: "send this message" }).body);
|
||||
}
|
||||
}
|
||||
return failure;
|
||||
}
|
||||
if (mountedTaskKey.current === taskKey) settleUncertainSubmission(submission);
|
||||
else if (taskKey) settleDraftSubmission(taskKey, submission.attemptId);
|
||||
return "sent";
|
||||
}
|
||||
|
||||
const resendSourceId = useId();
|
||||
const autoResend = useAutoResend({
|
||||
sourceId: `task-chat-composer:${resendSourceId}`,
|
||||
active: Boolean(replayableRequest(uncertainSubmission, draftOwner)) && !submitting,
|
||||
resend: resendUncertainSubmission,
|
||||
});
|
||||
|
||||
/** Stop resending; the text stays in the editor to edit or send again. */
|
||||
function cancelUncertainSubmission() {
|
||||
if (!uncertainSubmission) return;
|
||||
settledSubmissionIds.current.add(JSON.stringify([draftKey, uncertainSubmission.attemptId]));
|
||||
if (draftKey) clearDraftSubmission(draftKey, uncertainSubmission.attemptId);
|
||||
setUncertainSubmission(null);
|
||||
}
|
||||
|
||||
@@ -1290,43 +1370,16 @@ export function TaskChatComposer({
|
||||
>
|
||||
{creation?.header}
|
||||
{uncertainSubmission ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-3 space-y-2 rounded-md border border-border bg-muted p-3 text-sm"
|
||||
>
|
||||
<p>
|
||||
We couldn’t confirm whether this comment was saved. It may already
|
||||
be in the conversation. Review it before starting another draft.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={reviewUncertainSubmission}
|
||||
>
|
||||
Review conversation
|
||||
</Button>
|
||||
{reviewError ? (
|
||||
<p>Couldn’t refresh the conversation. Try reviewing it again.</p>
|
||||
) : null}
|
||||
{uncertainSubmission.reviewed ? (
|
||||
<>
|
||||
<p>
|
||||
Discarding this draft does not remove any saved comment or
|
||||
uploaded file.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={discardUncertainDraft}
|
||||
>
|
||||
Discard draft and start new
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<PendingSendNotice
|
||||
className="mb-3"
|
||||
noun="message"
|
||||
persisted={Boolean(draftKey && loadDraftSubmission(draftKey)?.attemptId === uncertainSubmission.attemptId && replayableRequest(loadDraftSubmission(draftKey), draftOwner))}
|
||||
resending={autoResend.resending}
|
||||
stalled={autoResend.stalled}
|
||||
onResendNow={replayableRequest(uncertainSubmission, draftOwner) ? autoResend.resendNow : undefined}
|
||||
onCancel={cancelUncertainSubmission}
|
||||
/>
|
||||
) : null}
|
||||
{!takeoverVisible && (pendingTakeover || takeover) ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1359,7 +1412,7 @@ export function TaskChatComposer({
|
||||
? (disabledReason ?? "Composer disabled")
|
||||
: effectivePlaceholder
|
||||
}
|
||||
readOnly={disabled || !!uncertainSubmission || Boolean(creation && submitting)}
|
||||
readOnly={disabled || !!uncertainSubmission || Boolean(creation && (submitting || creation.readOnly))}
|
||||
mentions={mentions}
|
||||
actionCommands={creation ? [] : conversationMode ? [{
|
||||
id: "action:new", kind: "action", command: "new", name: "New session",
|
||||
@@ -1434,6 +1487,15 @@ export function TaskChatComposer({
|
||||
</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
{attachment.status === "error" && attachment.file ? (
|
||||
<AttachmentAction
|
||||
aria-label={`Retry ${attachment.name}`}
|
||||
disabled={!!uncertainSubmission}
|
||||
onClick={() => retryUpload(attachment)}
|
||||
>
|
||||
<RotateCw aria-hidden />
|
||||
</AttachmentAction>
|
||||
) : null}
|
||||
<AttachmentAction
|
||||
aria-label={`Remove ${attachment.name}`}
|
||||
disabled={!!uncertainSubmission}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClientProvider, onlineManager, type QueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { createConnectivityStore, type ConnectivityStore } from "@/lib/connectivity";
|
||||
import { createAppQueryClient } from "@/lib/query-client";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { classifySendFailure } from "@/lib/pending-send";
|
||||
import { useAccountBoundMutation } from "./useAccountBoundMutation";
|
||||
|
||||
const lookup = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/api/auth", () => ({ authApi: { getSession: lookup } }));
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
let client: QueryClient;
|
||||
let store: ConnectivityStore;
|
||||
let current: UseMutationResult<string, Error, { id: string }>;
|
||||
const send = vi.fn();
|
||||
const success = vi.fn();
|
||||
const failure = vi.fn();
|
||||
let waitForTurn: (() => Promise<void>) | undefined;
|
||||
|
||||
function Harness({ target = "original" }: { target?: string }) {
|
||||
current = useAccountBoundMutation({
|
||||
meta: { replay: "idempotent" },
|
||||
mutationFn: async (input: { id: string }) => send(target, input),
|
||||
onSuccess: success,
|
||||
onError: failure,
|
||||
waitForTurn,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function render(target = "original") {
|
||||
act(() => root.render(<QueryClientProvider client={client}><Harness target={target} /></QueryClientProvider>));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
send.mockReset().mockResolvedValue("saved");
|
||||
success.mockReset();
|
||||
failure.mockReset();
|
||||
lookup.mockReset().mockReturnValue(new Promise(() => {}));
|
||||
waitForTurn = undefined;
|
||||
store = createConnectivityStore({ probe: async () => ({ reachable: true }), browserOnline: true });
|
||||
client = createAppQueryClient({ connectivity: store });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "account-a" } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
host.remove();
|
||||
client.clear();
|
||||
store.dispose();
|
||||
onlineManager.setOnline(true);
|
||||
});
|
||||
|
||||
it.each(["accept", "reject", "answer"])("does not send a paused %s after account change", async (id) => {
|
||||
onlineManager.setOnline(false);
|
||||
render();
|
||||
let pending!: Promise<unknown>;
|
||||
act(() => { pending = current.mutateAsync({ id }).catch(error => error); });
|
||||
await act(async () => {});
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await client.resetQueries({ queryKey: queryKeys.auth.session });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "account-b" } });
|
||||
onlineManager.setOnline(true);
|
||||
await client.resumePausedMutations();
|
||||
});
|
||||
expect(await pending).toMatchObject({ name: "AbortError" });
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks the account after waiting behind another write", async () => {
|
||||
let release!: () => void;
|
||||
waitForTurn = () => new Promise<void>(resolve => { release = resolve; });
|
||||
render();
|
||||
let pending!: Promise<unknown>;
|
||||
await act(async () => { pending = current.mutateAsync({ id: "queued" }).catch(error => error); });
|
||||
await act(async () => {
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "account-b" } });
|
||||
release();
|
||||
});
|
||||
expect(await pending).toMatchObject({ name: "AbortError" });
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains the original target when the mounted surface changes", async () => {
|
||||
onlineManager.setOnline(false);
|
||||
render("task-a");
|
||||
let pending!: Promise<unknown>;
|
||||
await act(async () => { pending = current.mutateAsync({ id: "interaction-a" }); });
|
||||
render("task-b");
|
||||
await act(async () => {
|
||||
onlineManager.setOnline(true);
|
||||
await client.resumePausedMutations();
|
||||
});
|
||||
expect(await pending).toBe("saved");
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith("task-a", { id: "interaction-a" });
|
||||
});
|
||||
|
||||
it("does not apply an old response to the next account", async () => {
|
||||
let resolve!: (result: string) => void;
|
||||
send.mockReturnValue(new Promise<string>(done => { resolve = done; }));
|
||||
render();
|
||||
let pending!: Promise<unknown>;
|
||||
await act(async () => { pending = current.mutateAsync({ id: "in-flight" }).catch(error => error); });
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "account-b" } });
|
||||
resolve("saved for account-a");
|
||||
});
|
||||
expect(await pending).toMatchObject({ name: "AbortError" });
|
||||
expect(success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an action pending through the first session lookup", async () => {
|
||||
client.removeQueries({ queryKey: queryKeys.auth.session });
|
||||
render();
|
||||
let pending!: Promise<unknown>;
|
||||
await act(async () => { pending = current.mutateAsync({ id: "startup" }).catch(error => error); });
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
render("another-task");
|
||||
await act(async () => { client.setQueryData(queryKeys.auth.session, { user: { id: "account-a" } }); });
|
||||
expect(await pending).toBe("saved");
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith("original", { id: "startup" });
|
||||
expect(success).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the result uncertain after an account round trip during an accepted POST", async () => {
|
||||
let accepted!: (result: string) => void;
|
||||
send.mockReturnValue(new Promise<string>(resolve => { accepted = resolve; }));
|
||||
render();
|
||||
let pending!: Promise<unknown>;
|
||||
await act(async () => { pending = current.mutateAsync({ id: "accepted" }).catch(error => error); });
|
||||
await act(async () => {
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "account-b" } });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "account-a" } });
|
||||
accepted("accepted by account-a");
|
||||
});
|
||||
const error = await pending;
|
||||
expect(error).toMatchObject({ name: "AbortError" });
|
||||
expect(classifySendFailure(error)).toBe("pending");
|
||||
});
|
||||
|
||||
it("restarts a failed initial session lookup for waiting work", async () => {
|
||||
client.removeQueries({ queryKey: queryKeys.auth.session });
|
||||
const error = new ApiError("Session lookup failed", 500, { error: "session_lookup_failed" });
|
||||
lookup.mockRejectedValueOnce(error).mockResolvedValue({ user: { id: "account-a" } });
|
||||
await client.fetchQuery({ queryKey: queryKeys.auth.session, queryFn: lookup, retry: false }).catch(() => {});
|
||||
render();
|
||||
let pending!: Promise<unknown>;
|
||||
await act(async () => { pending = current.mutateAsync({ id: "startup" }).catch(error => error); });
|
||||
let result: unknown;
|
||||
await act(async () => { result = await Promise.race([pending, new Promise(resolve => setTimeout(() => resolve("still waiting"), 1500))]); });
|
||||
expect(result).toBe("saved");
|
||||
expect(lookup).toHaveBeenCalledTimes(2);
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith("original", { id: "startup" });
|
||||
});
|
||||
|
||||
it("reports an exhausted session lookup instead of leaving the action pending", async () => {
|
||||
client.removeQueries({ queryKey: queryKeys.auth.session });
|
||||
const error = new ApiError("Session lookup failed", 500, { error: "session_lookup_failed" });
|
||||
lookup.mockRejectedValue(error);
|
||||
await client.fetchQuery({ queryKey: queryKeys.auth.session, queryFn: lookup, retry: false }).catch(() => {});
|
||||
render();
|
||||
let pending!: Promise<unknown>;
|
||||
await act(async () => { pending = current.mutateAsync({ id: "startup" }).catch(error => error); });
|
||||
let result: unknown;
|
||||
await act(async () => { result = await Promise.race([pending, new Promise(resolve => setTimeout(() => resolve("still waiting"), 2000))]); });
|
||||
expect(result).toBe(error);
|
||||
expect(failure).toHaveBeenCalledTimes(1);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
||||
expect(current.isPending).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useCallback, useContext } from "react";
|
||||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
QueryClientContext,
|
||||
type DefaultError,
|
||||
type MutateOptions,
|
||||
type QueryClient,
|
||||
type UseMutationOptions,
|
||||
type UseMutationResult,
|
||||
type MutationFunctionContext,
|
||||
} from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { classifySendFailure, PendingSendAccountChangedError } from "@/lib/pending-send";
|
||||
|
||||
type AccountGeneration = { owner: string | undefined; generation: number };
|
||||
const accounts = new WeakMap<QueryClient, AccountGeneration>();
|
||||
|
||||
function currentOwner(client: QueryClient): string | undefined {
|
||||
const session = client.getQueryData<{ user?: { id?: string }; session?: { userId?: string } } | null>(queryKeys.auth.session);
|
||||
return session === undefined ? undefined : session?.user?.id ?? session?.session?.userId ?? "signed-out";
|
||||
}
|
||||
|
||||
function bindAccount(client: QueryClient) {
|
||||
let account = accounts.get(client);
|
||||
if (!account) {
|
||||
account = { owner: currentOwner(client), generation: 0 };
|
||||
accounts.set(client, account);
|
||||
const tracked = account;
|
||||
client.getQueryCache().subscribe(event => {
|
||||
const owner = currentOwner(client);
|
||||
const removed = event.type === "removed" && JSON.stringify(event.query.queryKey) === JSON.stringify(queryKeys.auth.session);
|
||||
if (owner !== tracked.owner || removed) {
|
||||
if (removed || tracked.owner !== undefined || tracked.generation !== 0) tracked.generation += 1;
|
||||
tracked.owner = owner;
|
||||
}
|
||||
});
|
||||
}
|
||||
const { owner, generation } = account;
|
||||
const isCurrentScope = () => account.generation === generation && (owner === undefined ? generation === 0 : account.owner === owner);
|
||||
const isCurrentAccount = () => account.owner !== undefined && isCurrentScope();
|
||||
const whenAccountReady = async () => {
|
||||
if (account.owner !== undefined || !isCurrentScope()) return;
|
||||
let stopped = false;
|
||||
let unsubscribe = () => {};
|
||||
const ready = new Promise<void>(resolve => {
|
||||
unsubscribe = client.getQueryCache().subscribe(() => {
|
||||
if (account.owner !== undefined || !isCurrentScope()) resolve();
|
||||
});
|
||||
});
|
||||
const lookup = async () => {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
if (stopped || account.owner !== undefined || !isCurrentScope()) return;
|
||||
try {
|
||||
await client.fetchQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), retry: false });
|
||||
return;
|
||||
} catch (error) {
|
||||
if (stopped || account.owner !== undefined || !isCurrentScope()) return;
|
||||
if (attempt === 2 || classifySendFailure(error) !== "pending") throw error;
|
||||
await new Promise(resolve => setTimeout(resolve, 250 * 2 ** attempt));
|
||||
}
|
||||
}
|
||||
};
|
||||
try { await Promise.race([ready, lookup()]); }
|
||||
finally { stopped = true; unsubscribe(); }
|
||||
};
|
||||
return { isCurrentAccount, isCurrentScope, whenAccountReady };
|
||||
}
|
||||
|
||||
export function useCurrentAccountGuard(): () => boolean {
|
||||
const client = useContext(QueryClientContext);
|
||||
return client ? bindAccount(client).isCurrentAccount : () => true;
|
||||
}
|
||||
|
||||
type AccountMutationContext = MutationFunctionContext & { assertCurrentAccount: () => void };
|
||||
|
||||
export function useAccountBoundMutation<TData = unknown, TError = DefaultError, TVariables = void, TContext = unknown>(
|
||||
options: Omit<UseMutationOptions<TData, TError, TVariables, TContext>, "mutationFn"> & {
|
||||
mutationFn?: (variables: TVariables, context: AccountMutationContext) => Promise<TData>;
|
||||
waitForTurn?: (variables: TVariables) => Promise<void> | undefined;
|
||||
onFinished?: (variables: NoInfer<TVariables>, context: NoInfer<TContext> | undefined) => void;
|
||||
},
|
||||
): UseMutationResult<TData, TError, TVariables, TContext> {
|
||||
const client = useQueryClient();
|
||||
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>({
|
||||
...rest,
|
||||
mutationFn: async ({ input, isCurrentAccount, whenAccountReady, operation }, context) => {
|
||||
await whenAccountReady();
|
||||
await operation.waitForTurn?.(input);
|
||||
const assertCurrentAccount = () => {
|
||||
if (!isCurrentAccount()) {
|
||||
const error = new PendingSendAccountChangedError();
|
||||
error.name = "AbortError";
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
assertCurrentAccount();
|
||||
if (!operation.mutationFn) throw new Error("Missing account-bound mutation function");
|
||||
const result = await operation.mutationFn(input, { ...context, assertCurrentAccount });
|
||||
assertCurrentAccount();
|
||||
return result;
|
||||
},
|
||||
onMutate: async ({ input, isCurrentAccount, whenAccountReady, operation }, context) => {
|
||||
await whenAccountReady();
|
||||
return isCurrentAccount() ? operation.onMutate?.(input, context) : undefined;
|
||||
},
|
||||
onSuccess: (data, { input, isCurrentAccount, operation }, result, context) => isCurrentAccount() ? operation.onSuccess?.(data, input, result as TContext, context) : undefined,
|
||||
onError: (error, { input, isCurrentScope, operation }, result, context) => isCurrentScope() ? operation.onError?.(error, input, result, context) : undefined,
|
||||
onSettled: async (data, error, { input, operation, isCurrentScope }, result, context) => {
|
||||
operation.onFinished?.(input, result);
|
||||
if (isCurrentScope()) await operation.onSettled?.(data, error, input, result, context);
|
||||
},
|
||||
});
|
||||
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); },
|
||||
onSettled: (data, error, { input, isCurrentScope }, result, context) => { if (isCurrentScope()) callbacks?.onSettled?.(data, error, input, result, context); },
|
||||
});
|
||||
const mutate = useCallback((input: TVariables, callbacks?: MutateOptions<TData, TError, TVariables, TContext>) => {
|
||||
mutation.mutate({ input, ...bindAccount(client), operation: options }, wrapOptions(callbacks));
|
||||
}, [client, mutation.mutate, options]);
|
||||
const mutateAsync = useCallback((input: TVariables, callbacks?: MutateOptions<TData, TError, TVariables, TContext>) => {
|
||||
return mutation.mutateAsync({ input, ...bindAccount(client), operation: options }, wrapOptions(callbacks));
|
||||
}, [client, mutation.mutateAsync, options]);
|
||||
return { ...mutation, variables: mutation.variables?.input, mutate, mutateAsync } as UseMutationResult<TData, TError, TVariables, TContext>;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export type AutosaveState = "idle" | "saving" | "saved" | "error";
|
||||
|
||||
const SAVING_DELAY_MS = 250;
|
||||
const SAVED_LINGER_MS = 1600;
|
||||
|
||||
export function useAutosaveIndicator() {
|
||||
const [state, setState] = useState<AutosaveState>("idle");
|
||||
const saveIdRef = useRef(0);
|
||||
const savingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const clearSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (savingTimerRef.current) {
|
||||
clearTimeout(savingTimerRef.current);
|
||||
savingTimerRef.current = null;
|
||||
}
|
||||
if (clearSavedTimerRef.current) {
|
||||
clearTimeout(clearSavedTimerRef.current);
|
||||
clearSavedTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => clearTimers, [clearTimers]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
saveIdRef.current += 1;
|
||||
clearTimers();
|
||||
setState("idle");
|
||||
}, [clearTimers]);
|
||||
|
||||
const markDirty = useCallback(() => {
|
||||
clearTimers();
|
||||
setState("idle");
|
||||
}, [clearTimers]);
|
||||
|
||||
const runSave = useCallback(async (save: () => Promise<void>) => {
|
||||
const saveId = saveIdRef.current + 1;
|
||||
saveIdRef.current = saveId;
|
||||
clearTimers();
|
||||
savingTimerRef.current = setTimeout(() => {
|
||||
if (saveIdRef.current === saveId) {
|
||||
setState("saving");
|
||||
}
|
||||
}, SAVING_DELAY_MS);
|
||||
|
||||
try {
|
||||
await save();
|
||||
if (saveIdRef.current !== saveId) return;
|
||||
clearTimers();
|
||||
setState("saved");
|
||||
clearSavedTimerRef.current = setTimeout(() => {
|
||||
if (saveIdRef.current === saveId) {
|
||||
setState("idle");
|
||||
}
|
||||
}, SAVED_LINGER_MS);
|
||||
} catch (error) {
|
||||
if (saveIdRef.current !== saveId) throw error;
|
||||
clearTimers();
|
||||
setState("error");
|
||||
throw error;
|
||||
}
|
||||
}, [clearTimers]);
|
||||
|
||||
return {
|
||||
state,
|
||||
markDirty,
|
||||
reset,
|
||||
runSave,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useContext } from "react";
|
||||
import { QueryClient, QueryClientContext, useQuery } from "@tanstack/react-query";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
/** Keeps `useQuery` from throwing in renders without a QueryClientProvider; never fetches. */
|
||||
let detachedClient: QueryClient | null = null;
|
||||
function getDetachedClient(): QueryClient {
|
||||
detachedClient ??= new QueryClient();
|
||||
return detachedClient;
|
||||
}
|
||||
|
||||
/** Owner for renders without a QueryClientProvider (isolated test and story mounts). */
|
||||
export const DETACHED_DRAFT_OWNER = "detached";
|
||||
|
||||
/**
|
||||
* The account that owns drafts and pending sends kept in browser storage, so
|
||||
* another account in the same browser never sees or sends them. Undefined
|
||||
* until the session is known: callers neither restore nor resend until then.
|
||||
* Once known it stays known through a failed refetch (the query keeps its
|
||||
* data), so drafts keep being backed up during an outage.
|
||||
*/
|
||||
function sessionDraftOwner(session: Awaited<ReturnType<typeof authApi.getSession>> | undefined): string | undefined {
|
||||
if (session === undefined) return undefined;
|
||||
return session?.user?.id ?? session?.session?.userId ?? "signed-out";
|
||||
}
|
||||
|
||||
export function useDraftOwnerReader(): () => string | undefined {
|
||||
const client = useContext(QueryClientContext);
|
||||
return useCallback(() => client
|
||||
? sessionDraftOwner(client.getQueryData(queryKeys.auth.session))
|
||||
: DETACHED_DRAFT_OWNER, [client]);
|
||||
}
|
||||
|
||||
export function useDraftOwner(): string | undefined {
|
||||
const contextClient = useContext(QueryClientContext);
|
||||
const { data: session } = useQuery(
|
||||
{
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
enabled: contextClient != null,
|
||||
},
|
||||
contextClient ?? getDetachedClient(),
|
||||
);
|
||||
if (!contextClient) return DETACHED_DRAFT_OWNER;
|
||||
return sessionDraftOwner(session);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import {
|
||||
ConnectivityProvider,
|
||||
createConnectivityStore,
|
||||
type ConnectivityStore,
|
||||
type ProbeResult,
|
||||
} from "@/lib/connectivity";
|
||||
import { autosaveLabel, useDurableAutosave, type DurableAutosave, type DurableAutosaveOptions } from "./useDurableAutosave";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const outage = () => new ApiError("Paperclip is restarting", 503, { error: "tenant_app_unavailable" });
|
||||
const conflict = () => new ApiError("Document was updated by someone else", 409, { error: "Document was updated by someone else" });
|
||||
|
||||
describe("useDurableAutosave", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let store: ConnectivityStore;
|
||||
let probe: ReturnType<typeof vi.fn<() => Promise<ProbeResult>>>;
|
||||
let current: DurableAutosave<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
probe = vi.fn<() => Promise<ProbeResult>>().mockResolvedValue({ reachable: false, retryAfterMs: null });
|
||||
store = createConnectivityStore({ probe, browserOnline: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
store.dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function Harness(props: DurableAutosaveOptions<string>) {
|
||||
current = useDurableAutosave(props);
|
||||
return null;
|
||||
}
|
||||
|
||||
function render(options: Partial<DurableAutosaveOptions<string>> & Pick<DurableAutosaveOptions<string>, "save">) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ConnectivityProvider store={store}>
|
||||
<Harness sourceId="doc" action="save the document" {...options} />
|
||||
</ConnectivityProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const advance = (ms: number) => act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
|
||||
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);
|
||||
render({ save, matchesServer });
|
||||
await act(async () => { expect(await current.run("Accepted text")).toEqual({ kind: "waiting" }); });
|
||||
await advance(2_000);
|
||||
expect(matchesServer).toHaveBeenCalledTimes(2);
|
||||
expect(current.status).toBe("saved");
|
||||
});
|
||||
|
||||
it("retains queued writes for two targets while disconnected", async () => {
|
||||
store.setBrowserOnline(false);
|
||||
const first = vi.fn().mockResolvedValue(undefined), second = vi.fn().mockResolvedValue(undefined);
|
||||
render({ sourceId: "task-a", save: first });
|
||||
await act(async () => { await current.run("A title"); });
|
||||
render({ sourceId: "task-b", save: second });
|
||||
await act(async () => { await current.run("B title"); });
|
||||
expect(store.getSnapshot().pendingWrites).toBe(2);
|
||||
await act(async () => { store.setBrowserOnline(true); store.reportSuccess(); });
|
||||
await advance(2_000);
|
||||
expect(first).toHaveBeenCalledWith("A title");
|
||||
expect(second).toHaveBeenCalledWith("B title");
|
||||
});
|
||||
|
||||
it("finishes an admitted offline edit after its editor unmounts", async () => {
|
||||
store.setBrowserOnline(false);
|
||||
const first = vi.fn().mockResolvedValue(undefined), second = vi.fn().mockResolvedValue(undefined);
|
||||
render({ sourceId: "task-a", save: first });
|
||||
await act(async () => { await current.run("A title"); });
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
render({ sourceId: "task-b", save: second });
|
||||
await act(async () => { await current.run("B title"); });
|
||||
await act(async () => { store.setBrowserOnline(true); store.reportSuccess(); });
|
||||
await advance(2_000);
|
||||
expect(first).toHaveBeenCalledWith("A title");
|
||||
expect(second).toHaveBeenCalledWith("B title");
|
||||
expect(store.getSnapshot().pendingWrites).toBe(0);
|
||||
});
|
||||
it("never lets an old detached retry overwrite a newer edit after reopening the same target", async () => {
|
||||
let server = "Original";
|
||||
const oldSave = vi.fn().mockRejectedValueOnce(outage()).mockRejectedValueOnce(outage())
|
||||
.mockImplementation(async value => { server = value; });
|
||||
render({ sourceId: "editor-old", targetKey: "task-a:title", save: oldSave });
|
||||
await act(async () => { await current.run("Older title"); });
|
||||
act(() => root.unmount()); root = createRoot(container);
|
||||
await advance(0);
|
||||
expect(oldSave).toHaveBeenCalledTimes(2);
|
||||
const newSave = vi.fn(async value => { server = value; });
|
||||
render({ sourceId: "editor-new", targetKey: "task-a:title", save: newSave });
|
||||
await act(async () => { await current.run("Newer title"); });
|
||||
expect(server).toBe("Newer title");
|
||||
await advance(30_000);
|
||||
expect(server).toBe("Newer title");
|
||||
expect(oldSave).toHaveBeenCalledTimes(2);
|
||||
expect(store.getSnapshot().pendingWrites).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a detached edit when a reopened editor resets only its local state and storage is unavailable", async () => {
|
||||
const storage = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("Storage unavailable"); });
|
||||
try {
|
||||
store.setBrowserOnline(false);
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
render({ sourceId: "old-editor", targetKey: "task-a:description", storageKey: "draft:a", save });
|
||||
await act(async () => { current.persistDraft("Only remaining copy"); await current.run("Only remaining copy"); });
|
||||
act(() => root.unmount()); root = createRoot(container);
|
||||
render({ sourceId: "new-editor", targetKey: "task-a:description", storageKey: "draft:a", save });
|
||||
act(() => current.reset());
|
||||
await act(async () => { store.setBrowserOnline(true); store.reportSuccess(); });
|
||||
await advance(2_000);
|
||||
expect(save).toHaveBeenCalledExactlyOnceWith("Only remaining copy");
|
||||
} finally { storage.mockRestore(); }
|
||||
});
|
||||
|
||||
it("does not send a detached editor's private draft after an account switch", async () => {
|
||||
store.setBrowserOnline(false);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
act(() => root.render(<QueryClientProvider client={client}><ConnectivityProvider store={store}>
|
||||
<Harness sourceId="private-document" storageKey="user-1:document" save={save} />
|
||||
</ConnectivityProvider></QueryClientProvider>));
|
||||
await act(async () => { current.persistDraft("Private text"); await current.run("Private text"); });
|
||||
act(() => root.unmount()); root = createRoot(container);
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } });
|
||||
await act(async () => { store.setBrowserOnline(true); store.reportSuccess(); });
|
||||
await advance(2_000);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem("user-1:document")).toContain("Private text");
|
||||
expect(store.getSnapshot().pendingWrites).toBe(0);
|
||||
client.clear();
|
||||
});
|
||||
|
||||
it("keeps an admitted startup save through the first account lookup", async () => {
|
||||
store.setBrowserOnline(false);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, enabled: false } } });
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
act(() => root.render(<QueryClientProvider client={client}><ConnectivityProvider store={store}>
|
||||
<Harness sourceId="startup-title" save={save} />
|
||||
</ConnectivityProvider></QueryClientProvider>));
|
||||
await act(async () => { await current.run("Startup title"); });
|
||||
expect(current.status).toBe("waiting");
|
||||
act(() => client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }));
|
||||
await advance(0);
|
||||
await act(async () => { store.setBrowserOnline(true); store.reportSuccess(); });
|
||||
await advance(2_000);
|
||||
expect(save).toHaveBeenCalledExactlyOnceWith("Startup title");
|
||||
expect(store.getSnapshot().pendingWrites).toBe(0);
|
||||
client.clear();
|
||||
});
|
||||
|
||||
it("bounds retries after an editor unmounts", async () => {
|
||||
const save = vi.fn().mockRejectedValue(outage());
|
||||
render({ save });
|
||||
await act(async () => { await current.run("Pending text"); });
|
||||
act(() => root.unmount()); root = createRoot(container);
|
||||
for (let attempt = 0; attempt < 10; attempt++) await advance(30_000);
|
||||
expect(save).toHaveBeenCalledTimes(9);
|
||||
expect(store.getSnapshot().pendingWrites).toBe(0);
|
||||
});
|
||||
|
||||
it("exposes a manual retry when automatic autosaves stop", async () => {
|
||||
const save = vi.fn().mockRejectedValue(outage());
|
||||
render({ save });
|
||||
await act(async () => { await current.run("Pending text"); });
|
||||
for (let attempt = 0; attempt < 10; attempt++) await advance(30_000);
|
||||
expect(current.status).toBe("stalled");
|
||||
const before = save.mock.calls.length;
|
||||
await act(async () => { current.resendNow(); });
|
||||
expect(save.mock.calls.length).toBe(before + 1);
|
||||
});
|
||||
|
||||
it("treats a 409 whose server copy equals the draft as saved", async () => {
|
||||
// The first save landed but its response was lost; the retry's base
|
||||
// revision is stale, so the server answers 409 with our own text.
|
||||
let serverBody = "Old text";
|
||||
const save = vi.fn(async (draft: string) => {
|
||||
if (save.mock.calls.length === 1) {
|
||||
serverBody = draft;
|
||||
throw outage();
|
||||
}
|
||||
if (serverBody === draft) throw conflict();
|
||||
serverBody = draft;
|
||||
});
|
||||
const matchesServer = vi.fn(async (_error: unknown, draft: string) => serverBody === draft);
|
||||
render({ save, matchesServer });
|
||||
|
||||
let result: Awaited<ReturnType<typeof current.run>> | undefined;
|
||||
await act(async () => {
|
||||
result = await current.run("New text");
|
||||
});
|
||||
expect(result).toEqual({ kind: "waiting" });
|
||||
expect(current.status).toBe("waiting");
|
||||
expect(current.label).toBe("Will save when reconnected");
|
||||
expect(store.getSnapshot().pendingWrites).toBe(1);
|
||||
|
||||
await advance(1_000);
|
||||
expect(save).toHaveBeenCalledTimes(2);
|
||||
expect(matchesServer).toHaveBeenCalledWith(expect.any(ApiError), "New text");
|
||||
expect(current.status).toBe("saved");
|
||||
expect(current.label).toBe("Saved");
|
||||
expect(store.getSnapshot().pendingWrites).toBe(0);
|
||||
});
|
||||
|
||||
it("reports a 409 for different server text as a failure for the caller's conflict view", async () => {
|
||||
const save = vi.fn().mockRejectedValue(conflict());
|
||||
render({ save, matchesServer: () => false });
|
||||
let result: Awaited<ReturnType<typeof current.run>> | undefined;
|
||||
await act(async () => {
|
||||
result = await current.run("Mine");
|
||||
});
|
||||
expect(result).toMatchObject({ kind: "failed" });
|
||||
expect(current.status).toBe("error");
|
||||
expect(current.label).toBe("Couldn't save (Document was updated by someone else)");
|
||||
});
|
||||
|
||||
it("holds the draft during an outage and saves the newest one after recovery", async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
render({ save });
|
||||
store.reportError(outage());
|
||||
await advance(0);
|
||||
expect(store.getSnapshot().status).toBe("reconnecting");
|
||||
|
||||
await act(async () => {
|
||||
await current.run("First edit");
|
||||
});
|
||||
await act(async () => {
|
||||
await current.run("First edit, then more");
|
||||
});
|
||||
// Nothing is sent into the outage.
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(current.label).toBe("Will save when reconnected");
|
||||
|
||||
probe.mockResolvedValue({ reachable: true });
|
||||
await act(async () => {
|
||||
store.probeNow();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
await advance(0);
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(save).toHaveBeenCalledWith("First edit, then more");
|
||||
expect(current.status).toBe("saved");
|
||||
});
|
||||
|
||||
it("saves a queued draft with the save function it was queued with", async () => {
|
||||
// The editor stays mounted while the user opens another record; the
|
||||
// waiting edit still belongs to the first one.
|
||||
const saveFirst = vi.fn().mockResolvedValue(undefined);
|
||||
const saveSecond = vi.fn().mockResolvedValue(undefined);
|
||||
render({ save: saveFirst });
|
||||
store.reportError(outage());
|
||||
await advance(0);
|
||||
await act(async () => {
|
||||
await current.run("Title for the first task");
|
||||
});
|
||||
expect(current.status).toBe("waiting");
|
||||
|
||||
render({ save: saveSecond });
|
||||
probe.mockResolvedValue({ reachable: true });
|
||||
await act(async () => {
|
||||
store.probeNow();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
await advance(0);
|
||||
expect(saveFirst).toHaveBeenCalledTimes(1);
|
||||
expect(saveFirst).toHaveBeenCalledWith("Title for the first task");
|
||||
expect(saveSecond).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hands a definitive failure of a background retry to the job's caller", async () => {
|
||||
const save = vi.fn()
|
||||
.mockRejectedValueOnce(outage())
|
||||
.mockRejectedValueOnce(conflict());
|
||||
const onRetryFailed = vi.fn();
|
||||
render({ save: vi.fn() });
|
||||
await act(async () => {
|
||||
await current.run("Mine", { save, onRetryFailed });
|
||||
});
|
||||
expect(current.status).toBe("waiting");
|
||||
expect(onRetryFailed).not.toHaveBeenCalled();
|
||||
|
||||
await advance(1_000);
|
||||
expect(save).toHaveBeenCalledTimes(2);
|
||||
expect(current.status).toBe("error");
|
||||
expect(onRetryFailed).toHaveBeenCalledTimes(1);
|
||||
expect(onRetryFailed).toHaveBeenCalledWith(expect.objectContaining({ status: 409 }));
|
||||
});
|
||||
|
||||
it("never rejects and shows readable copy for a definitive failure", async () => {
|
||||
const save = vi.fn().mockRejectedValue(new ApiError("tenant_access_denied", 403, { error: "tenant_access_denied" }));
|
||||
render({ save });
|
||||
await expect(current.run("Text")).resolves.toMatchObject({ kind: "failed" });
|
||||
await advance(0);
|
||||
expect(current.status).toBe("error");
|
||||
expect(current.label).toBe("Couldn't save (You don’t have permission to do that)");
|
||||
await advance(60_000);
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps a stored draft until that exact text is saved", async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
render({ save, storageKey: "draft:doc" });
|
||||
act(() => current.persistDraft("Unsaved words"));
|
||||
expect(current.readStoredDraft()).toBe("Unsaved words");
|
||||
await act(async () => {
|
||||
await current.run("Older words");
|
||||
});
|
||||
expect(current.readStoredDraft()).toBe("Unsaved words");
|
||||
await act(async () => {
|
||||
await current.run("Unsaved words");
|
||||
});
|
||||
expect(current.readStoredDraft()).toBeNull();
|
||||
});
|
||||
|
||||
it("labels each state", () => {
|
||||
expect(autosaveLabel("idle", null)).toBeNull();
|
||||
expect(autosaveLabel("saving", null)).toBe("Saving…");
|
||||
expect(autosaveLabel("saved", null)).toBe("Saved");
|
||||
expect(autosaveLabel("waiting", null)).toBe("Will save when reconnected");
|
||||
expect(autosaveLabel("error", { title: "x", body: "Not allowed.", retryable: false })).toBe("Couldn't save (Not allowed)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import { classifyError, describeError, type ErrorDescription } from "@/api/errors";
|
||||
import { loadStructuredDraft, saveStructuredDraft } from "@/lib/composer-draft";
|
||||
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";
|
||||
|
||||
export type DurableAutosaveStatus =
|
||||
| "idle"
|
||||
| "saving"
|
||||
| "saved"
|
||||
/** Kept locally; it saves when the connection allows. */
|
||||
| "waiting"
|
||||
| "stalled"
|
||||
| "error";
|
||||
|
||||
export type DurableAutosaveResult =
|
||||
| { kind: "saved" }
|
||||
| { kind: "waiting" }
|
||||
/** A definitive failure; the caller may handle it (a conflict view, a lock notice). */
|
||||
| { kind: "failed"; error: unknown };
|
||||
|
||||
/** Copy for the autosave indicator. */
|
||||
export function autosaveLabel(status: DurableAutosaveStatus, error: ErrorDescription | null): string | null {
|
||||
switch (status) {
|
||||
case "saving":
|
||||
return "Saving…";
|
||||
case "saved":
|
||||
return "Saved";
|
||||
case "waiting":
|
||||
return "Will save when reconnected";
|
||||
case "stalled":
|
||||
return "Automatic saving paused";
|
||||
case "error":
|
||||
return error ? `Couldn't save (${error.body.replace(/\.$/, "")})` : "Couldn't save";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DurableAutosaveOptions<T> {
|
||||
sourceId: string;
|
||||
targetKey?: string;
|
||||
/** Writes `draft`. Only absolute-value writes (PUT a body, PATCH a field) are safe to retry. */
|
||||
save: (draft: T) => Promise<unknown>;
|
||||
/**
|
||||
* A 409 can be a replay after a lost response: our earlier save landed, so
|
||||
* the retry's base revision is stale. Return true when the server already
|
||||
* holds `draft`; the save then counts as done.
|
||||
*/
|
||||
matchesServer?: (error: unknown, draft: T) => Promise<boolean> | boolean;
|
||||
/** Persist unsaved drafts here (localStorage); null keeps them in memory. */
|
||||
storageKey?: string | null;
|
||||
/** For error copy: "save the document". */
|
||||
action?: string;
|
||||
}
|
||||
|
||||
/** Per-save overrides: a caller can bind the request and the 409 check to one draft. */
|
||||
export interface DurableAutosaveJob<T> {
|
||||
save?: (draft: T) => Promise<unknown>;
|
||||
matchesServer?: (error: unknown, draft: T) => Promise<boolean> | boolean;
|
||||
/**
|
||||
* A background retry of this draft failed definitively. `run` returns that
|
||||
* outcome to its caller, but nobody awaits an automatic retry.
|
||||
*/
|
||||
onRetryFailed?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface DurableAutosave<T> {
|
||||
status: DurableAutosaveStatus;
|
||||
error: ErrorDescription | null;
|
||||
label: string | null;
|
||||
/** Save now. Never rejects: failures become a status, so no rejection goes unhandled. */
|
||||
run: (draft: T, job?: DurableAutosaveJob<T>) => Promise<DurableAutosaveResult>;
|
||||
/** The draft changed; drop a stale "Saved" or error. */
|
||||
markDirty: () => 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. */
|
||||
persistDraft: (draft: T) => void;
|
||||
/** An unsaved draft from an earlier session, if any. */
|
||||
readStoredDraft: () => T | null;
|
||||
clearStoredDraft: () => void;
|
||||
resendNow: () => void;
|
||||
}
|
||||
|
||||
type AutosaveRevision = { latest: symbol; active: Set<symbol> };
|
||||
const latestAutosaves = new WeakMap<object, Map<string, AutosaveRevision>>();
|
||||
function autosaveRegistry(store: object): Map<string, AutosaveRevision> {
|
||||
let registry = latestAutosaves.get(store);
|
||||
if (!registry) { registry = new Map(); latestAutosaves.set(store, registry); }
|
||||
return registry;
|
||||
}
|
||||
|
||||
const SAVING_DELAY_MS = 250;
|
||||
const SAVED_LINGER_MS = 1600;
|
||||
|
||||
/**
|
||||
* Autosave that does not lose text across outages:
|
||||
*
|
||||
* - The draft can be persisted on every change (`persistDraft`) and is
|
||||
* cleared only after a confirmed save.
|
||||
* - A transient failure keeps the draft and saves it again with backoff and
|
||||
* right after the connection recovers. While the app is offline it does not
|
||||
* send at all.
|
||||
* - A 409 whose server copy equals our draft counts as saved.
|
||||
* - Errors become readable copy for "Couldn't save (reason)".
|
||||
*/
|
||||
export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): DurableAutosave<T> {
|
||||
const owner = useDraftOwner();
|
||||
const readOwner = useDraftOwnerReader();
|
||||
const instanceId = useId();
|
||||
const detachedWork = useRef<() => void>(() => {});
|
||||
const detachedWake = useRef<(() => void) | null>(null);
|
||||
const ownerRef = useRef(owner);
|
||||
ownerRef.current = owner;
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
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; draft: T;
|
||||
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>());
|
||||
const generations = useRef(new Map<string, number>());
|
||||
const sequence = useRef(0);
|
||||
const [, refresh] = useState(0);
|
||||
type View = { target: string; status: DurableAutosaveStatus; error: ErrorDescription | null };
|
||||
const [view, setViewState] = useState<View>({ target: targetKey, status: "idle", error: null });
|
||||
const setView = useCallback((next: View | ((current: View) => View)) => {
|
||||
setViewState(current => {
|
||||
const value = typeof next === "function" ? next(current) : next;
|
||||
return current.target === value.target && current.status === value.status && current.error === value.error ? current : value;
|
||||
});
|
||||
}, []);
|
||||
const store = useConnectivityStore();
|
||||
const registry = autosaveRegistry(store);
|
||||
const releaseJob = (job: Job) => {
|
||||
const entry = registry.get(job.registryKey);
|
||||
entry?.active.delete(job.token);
|
||||
if (entry && !entry.active.size) registry.delete(job.registryKey);
|
||||
};
|
||||
const mounted = useRef(true);
|
||||
const savingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const savedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const clearTimers = useCallback(() => {
|
||||
if (savingTimer.current) clearTimeout(savingTimer.current);
|
||||
if (savedTimer.current) clearTimeout(savedTimer.current);
|
||||
savingTimer.current = null;
|
||||
savedTimer.current = null;
|
||||
}, []);
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; clearTimers(); detachedWork.current(); }; }, [clearTimers]);
|
||||
const previousOwner = useRef(owner);
|
||||
useEffect(() => {
|
||||
const previous = previousOwner.current;
|
||||
previousOwner.current = owner;
|
||||
if (previous === owner) return;
|
||||
if (previous === undefined && owner !== undefined) {
|
||||
for (const job of jobs.current.values()) {
|
||||
if (job.owner !== undefined) continue;
|
||||
const nextKey = JSON.stringify([owner, job.target]);
|
||||
const superseded = registry.get(job.registryKey)?.latest !== job.token || registry.has(nextKey);
|
||||
releaseJob(job);
|
||||
if (superseded) {
|
||||
jobs.current.delete(job.target);
|
||||
if (generations.current.get(job.target) === job.id) generations.current.delete(job.target);
|
||||
continue;
|
||||
}
|
||||
job.owner = owner;
|
||||
job.registryKey = nextKey;
|
||||
registry.set(nextKey, { latest: job.token, active: new Set([job.token]) });
|
||||
}
|
||||
refresh(value => value + 1);
|
||||
return;
|
||||
}
|
||||
for (const job of jobs.current.values()) releaseJob(job);
|
||||
jobs.current.clear(); generations.current.clear(); clearTimers();
|
||||
setView({ target: targetRef.current, status: "idle", error: null });
|
||||
refresh(value => value + 1);
|
||||
}, [owner, clearTimers]);
|
||||
const storageKey = options.storageKey ?? null;
|
||||
const persistDraft = useCallback((draft: T) => {
|
||||
if (storageKey) saveStructuredDraft(storageKey, { version: 1, draft });
|
||||
}, [storageKey]);
|
||||
const clearStoredDraft = useCallback(() => {
|
||||
if (!storageKey) return;
|
||||
try { localStorage.removeItem(storageKey); } catch {}
|
||||
}, [storageKey]);
|
||||
const readStoredDraft = useCallback((): T | null => {
|
||||
if (!storageKey) return null;
|
||||
const stored = loadStructuredDraft<{ version?: unknown; draft?: T } | null>(storageKey, null);
|
||||
return stored?.version === 1 && stored.draft !== undefined ? stored.draft : null;
|
||||
}, [storageKey]);
|
||||
const execute = useCallback(async (job: Job, retry = false): Promise<DurableAutosaveResult> => {
|
||||
const owned = () => readOwner() === job.owner && generations.current.get(job.target) === job.id && registry.get(job.registryKey)?.latest === job.token;
|
||||
const visible = () => mounted.current && owned() && targetRef.current === job.target;
|
||||
const publish = (status: DurableAutosaveStatus, error: ErrorDescription | null = null) => {
|
||||
if (visible()) setView({ target: job.target, status, error });
|
||||
};
|
||||
if (!owned()) {
|
||||
if (jobs.current.get(job.target) === job) jobs.current.delete(job.target);
|
||||
releaseJob(job);
|
||||
if (mounted.current) refresh(value => value + 1);
|
||||
return { kind: "waiting" };
|
||||
}
|
||||
if (job.saving) return { kind: "waiting" };
|
||||
if (visible()) clearTimers();
|
||||
if (store.getSnapshot().status !== "online") { publish("waiting"); return { kind: "waiting" }; }
|
||||
if (retry) job.resends += 1;
|
||||
job.saving = true;
|
||||
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);
|
||||
if (mounted.current) refresh(value => value + 1);
|
||||
};
|
||||
const done = (): DurableAutosaveResult => {
|
||||
if (job.storageKey) {
|
||||
const stored = loadStructuredDraft<{ draft?: T } | null>(job.storageKey, null);
|
||||
if (stored && JSON.stringify(stored.draft) === JSON.stringify(job.draft)) {
|
||||
try { localStorage.removeItem(job.storageKey); } catch {}
|
||||
}
|
||||
}
|
||||
if (visible()) {
|
||||
clearTimers(); publish("saved");
|
||||
savedTimer.current = setTimeout(() => {
|
||||
if (mounted.current && readOwner() === job.owner && generations.current.get(job.target) === job.id && targetRef.current === job.target)
|
||||
setView(current => current.target === job.target && current.status === "saved" ? { ...current, status: "idle" } : current);
|
||||
}, SAVED_LINGER_MS);
|
||||
}
|
||||
remove();
|
||||
return { kind: "saved" };
|
||||
};
|
||||
try { await job.save(job.draft); return done(); }
|
||||
catch (initialCause) {
|
||||
let cause = initialCause;
|
||||
if (classifyError(cause) === "conflict" && job.matchesServer) {
|
||||
try { if (await job.matchesServer(cause, job.draft)) return done(); }
|
||||
catch (verificationError) { cause = verificationError; }
|
||||
}
|
||||
if (classifySendFailure(cause) === "pending") {
|
||||
if (visible()) clearTimers();
|
||||
publish("waiting"); return { kind: "waiting" };
|
||||
}
|
||||
if (visible()) clearTimers();
|
||||
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]);
|
||||
const run = useCallback(async (draft: T, overrides: DurableAutosaveJob<T> = {}) => {
|
||||
const current = optionsRef.current;
|
||||
const target = current.targetKey ?? current.sourceId;
|
||||
const admittedOwner = ownerRef.current ?? readOwner();
|
||||
const job: Job = { token: Symbol(), registryKey: JSON.stringify([admittedOwner, target]), resends: 0, id: ++sequence.current, target, owner: admittedOwner, draft,
|
||||
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);
|
||||
let revision = registry.get(job.registryKey);
|
||||
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);
|
||||
refresh(value => value + 1);
|
||||
return execute(job);
|
||||
}, [execute, readOwner]);
|
||||
const waitingJobs = [...jobs.current.values()].filter(job => !job.saving).length;
|
||||
const auto = useAutoResend({ sourceId: options.sourceId, active: waitingJobs > 0, pendingCount: waitingJobs,
|
||||
resend: async (): Promise<SendOutcome> => {
|
||||
for (const job of [...jobs.current.values()]) {
|
||||
if (jobs.current.get(job.target) !== job) continue;
|
||||
const result = await execute(job, true);
|
||||
if (result.kind === "failed") job.onRetryFailed?.(result.error);
|
||||
}
|
||||
return jobs.current.size ? "pending" : "sent";
|
||||
},
|
||||
});
|
||||
detachedWork.current = () => {
|
||||
if (!jobs.current.size) return;
|
||||
if (auto.stalled) { for (const job of jobs.current.values()) releaseJob(job); jobs.current.clear(); return; }
|
||||
const admittedOwner = ownerRef.current;
|
||||
const source = `${optionsRef.current.sourceId}:detached:${instanceId}`;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let disposed = false, running = false;
|
||||
let unsubscribe = () => {};
|
||||
const release = () => {
|
||||
disposed = true;
|
||||
if (!mounted.current) {
|
||||
for (const job of jobs.current.values()) releaseJob(job);
|
||||
jobs.current.clear();
|
||||
}
|
||||
if (timer) clearTimeout(timer);
|
||||
unsubscribe(); detachedWake.current = null;
|
||||
store.setPendingWrites(source, 0);
|
||||
};
|
||||
const wake = async () => {
|
||||
if (disposed || running) return;
|
||||
if (mounted.current || readOwner() !== admittedOwner || admittedOwner === undefined) { release(); return; }
|
||||
if (!jobs.current.size) { release(); return; }
|
||||
store.setPendingWrites(source, jobs.current.size);
|
||||
if (store.getSnapshot().status !== "online") return;
|
||||
running = true;
|
||||
for (const job of [...jobs.current.values()]) {
|
||||
if (job.saving || job.resends >= MAX_ONLINE_RESENDS) continue;
|
||||
const result = await execute(job, true);
|
||||
if (result.kind === "failed") job.onRetryFailed?.(result.error);
|
||||
}
|
||||
running = false;
|
||||
if (disposed) return;
|
||||
const waiting = [...jobs.current.values()].filter(job => !job.saving && job.resends < MAX_ONLINE_RESENDS);
|
||||
if (!waiting.length && ![...jobs.current.values()].some(job => job.saving)) { release(); return; }
|
||||
store.setPendingWrites(source, jobs.current.size);
|
||||
if (waiting.length && store.getSnapshot().status === "online") {
|
||||
timer = setTimeout(() => { timer = null; void wake(); }, resendDelayFor(Math.max(...waiting.map(job => job.resends))));
|
||||
}
|
||||
};
|
||||
detachedWake.current = () => { if (!running) void wake(); };
|
||||
unsubscribe = store.onRecover(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
for (const job of jobs.current.values()) job.resends = 0;
|
||||
void wake();
|
||||
});
|
||||
store.setPendingWrites(source, jobs.current.size);
|
||||
timer = setTimeout(() => { timer = null; void wake(); }, 0);
|
||||
};
|
||||
|
||||
const markDirty = useCallback(() => {
|
||||
clearTimers();
|
||||
setView(current => ({ target: targetRef.current, status: jobs.current.has(targetRef.current) ? "waiting" : "idle", error: null }));
|
||||
}, [clearTimers]);
|
||||
const reset = useCallback(() => {
|
||||
const job = jobs.current.get(targetRef.current);
|
||||
if (job) {
|
||||
const revision = registry.get(job.registryKey);
|
||||
if (revision?.latest === job.token) revision.latest = Symbol();
|
||||
releaseJob(job);
|
||||
}
|
||||
generations.current.delete(targetRef.current); const removed = jobs.current.delete(targetRef.current); clearTimers();
|
||||
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,
|
||||
persistDraft, readStoredDraft, clearStoredDraft, resendNow: auto.resendNow };
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, useState, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { PendingSendNotice } from "@/components/PendingSendNotice";
|
||||
import { CommentSubmissionUnknownError } from "@/lib/comment-submit-result";
|
||||
import {
|
||||
ConnectivityProvider,
|
||||
createConnectivityStore,
|
||||
type ConnectivityStore,
|
||||
type ProbeResult,
|
||||
} from "@/lib/connectivity";
|
||||
import { MAX_ONLINE_RESENDS, pendingSendKeyFor, readPendingSend, writePendingSend } from "@/lib/pending-send";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { DETACHED_DRAFT_OWNER } from "./useDraftOwner";
|
||||
import { useAutoResend, useDurableSubmit, type AutoResendState, type DurableSubmit, type DurableSubmitOptions } from "./useDurableSubmit";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
// The session lookup never answers on its own; tests set the account directly.
|
||||
vi.mock("@/api/auth", () => ({
|
||||
authApi: { getSession: () => new Promise(() => {}) },
|
||||
}));
|
||||
|
||||
type Payload = { body: string };
|
||||
const isPayload = (value: unknown): value is Payload =>
|
||||
!!value && typeof value === "object" && typeof (value as Payload).body === "string";
|
||||
|
||||
const KEY = "test:pending-send";
|
||||
/** Mounted without a QueryClientProvider, records belong to the detached owner. */
|
||||
const STORED_KEY = pendingSendKeyFor(KEY, DETACHED_DRAFT_OWNER);
|
||||
const outage = () => new ApiError("Paperclip is restarting", 503, { error: "tenant_app_unavailable" });
|
||||
|
||||
/**
|
||||
* A server that dedupes by idempotency key, like `clientRequestId` on comments:
|
||||
* a repeated key returns the first result instead of creating another row.
|
||||
*/
|
||||
function createDedupingServer() {
|
||||
const rows = new Map<string, { id: string; body: string }>();
|
||||
return {
|
||||
rows,
|
||||
post(payload: Payload, key: string) {
|
||||
const existing = rows.get(key);
|
||||
if (existing) return existing;
|
||||
const row = { id: `row-${rows.size + 1}`, body: payload.body };
|
||||
rows.set(key, row);
|
||||
return row;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useDurableSubmit", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let store: ConnectivityStore;
|
||||
let probe: ReturnType<typeof vi.fn<() => Promise<ProbeResult>>>;
|
||||
let current: DurableSubmit<Payload, { id: string; body: string }>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
probe = vi.fn<() => Promise<ProbeResult>>().mockResolvedValue({ reachable: false, retryAfterMs: null });
|
||||
store = createConnectivityStore({ probe, browserOnline: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
store.dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function Harness(props: DurableSubmitOptions<Payload, { id: string; body: string }>) {
|
||||
current = useDurableSubmit(props);
|
||||
return current.pending ? <PendingSendNotice persisted={current.persisted} resending={current.resending}
|
||||
stalled={current.status === "stalled"} onResendNow={current.resendNow} onCancel={current.cancel} /> : null;
|
||||
}
|
||||
|
||||
function render(options: Partial<DurableSubmitOptions<Payload, { id: string; body: string }>> & Pick<DurableSubmitOptions<Payload, { id: string; body: string }>, "send">) {
|
||||
const wrap = (children: ReactNode) => <ConnectivityProvider store={store}>{children}</ConnectivityProvider>;
|
||||
act(() => {
|
||||
root.render(wrap(<Harness sourceId="test" storageKey={KEY} isPayload={isPayload} {...options} />));
|
||||
});
|
||||
}
|
||||
|
||||
const flush = () => act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
const advance = (ms: number) => act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
|
||||
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()) });
|
||||
await act(async () => { await current.submit({ body: "Keep this text" }); });
|
||||
expect(current.pending?.payload.body).toBe("Keep this text");
|
||||
expect(current.persisted).toBe(false);
|
||||
expect(container.textContent).toContain("Browser storage is unavailable");
|
||||
expect(container.textContent).not.toContain("saved on this device");
|
||||
expect(readPendingSend(STORED_KEY, isPayload)).toBeNull();
|
||||
storage.mockRestore();
|
||||
});
|
||||
it("persists the payload and key before the request and clears them after a receipt", async () => {
|
||||
const server = createDedupingServer();
|
||||
let storedDuringRequest: unknown = null;
|
||||
const send = vi.fn(async (payload: Payload, key: string) => {
|
||||
storedDuringRequest = readPendingSend(STORED_KEY, isPayload);
|
||||
return server.post(payload, key);
|
||||
});
|
||||
render({ send });
|
||||
|
||||
let result: Awaited<ReturnType<typeof current.submit>> | undefined;
|
||||
await act(async () => {
|
||||
result = await current.submit({ body: "Hello" });
|
||||
});
|
||||
|
||||
expect(storedDuringRequest).toMatchObject({ payload: { body: "Hello" }, idempotencyKey: send.mock.calls[0]![1] });
|
||||
expect(result).toMatchObject({ kind: "sent", result: { body: "Hello" } });
|
||||
expect(readPendingSend(STORED_KEY, isPayload)).toBeNull();
|
||||
expect(current.status).toBe("idle");
|
||||
});
|
||||
|
||||
it("resends with the same key after an outage and the server keeps exactly one row", async () => {
|
||||
const server = createDedupingServer();
|
||||
// The first request commits on the server, then the response is lost.
|
||||
const send = vi.fn(async (payload: Payload, key: string) => {
|
||||
const row = server.post(payload, key);
|
||||
if (send.mock.calls.length === 1) throw new CommentSubmissionUnknownError();
|
||||
return row;
|
||||
});
|
||||
render({ send });
|
||||
|
||||
let first: Awaited<ReturnType<typeof current.submit>> | undefined;
|
||||
await act(async () => {
|
||||
first = await current.submit({ body: "Typed during the outage" });
|
||||
});
|
||||
expect(first?.kind).toBe("pending");
|
||||
expect(current.status).toBe("waiting");
|
||||
expect(store.getSnapshot().pendingWrites).toBe(1);
|
||||
expect(readPendingSend(STORED_KEY, isPayload)?.payload.body).toBe("Typed during the outage");
|
||||
|
||||
// The connection drops before the scheduled resend: nothing is sent.
|
||||
store.reportError(outage());
|
||||
await flush();
|
||||
expect(store.getSnapshot().status).toBe("reconnecting");
|
||||
await advance(60_000);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Recovery resends right away with the original key.
|
||||
probe.mockResolvedValue({ reachable: true });
|
||||
await act(async () => {
|
||||
store.probeNow();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
await flush();
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(send.mock.calls[1]![1]).toBe(send.mock.calls[0]![1]);
|
||||
expect(server.rows.size).toBe(1);
|
||||
expect(current.status).toBe("idle");
|
||||
expect(current.pending).toBeNull();
|
||||
expect(readPendingSend(STORED_KEY, isPayload)).toBeNull();
|
||||
expect(store.getSnapshot().pendingWrites).toBe(0);
|
||||
});
|
||||
|
||||
it("resends with backoff while the app stays online", async () => {
|
||||
const send = vi.fn()
|
||||
.mockRejectedValueOnce(outage())
|
||||
.mockRejectedValueOnce(outage())
|
||||
.mockResolvedValue({ id: "row-1", body: "Retry me" });
|
||||
render({ send });
|
||||
await act(async () => {
|
||||
await current.submit({ body: "Retry me" });
|
||||
});
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
await advance(1_000);
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
await advance(2_000);
|
||||
expect(send).toHaveBeenCalledTimes(3);
|
||||
expect(new Set(send.mock.calls.map((call) => call[1])).size).toBe(1);
|
||||
expect(current.status).toBe("idle");
|
||||
});
|
||||
|
||||
it("puts a definitive rejection back with readable copy and never resends it", async () => {
|
||||
const onRejected = vi.fn();
|
||||
const send = vi.fn().mockRejectedValue(new ApiError("Forbidden", 403, { error: "Forbidden" }));
|
||||
render({ send, onRejected, action: "post the comment" });
|
||||
let result: Awaited<ReturnType<typeof current.submit>> | undefined;
|
||||
await act(async () => {
|
||||
result = await current.submit({ body: "Not allowed" });
|
||||
});
|
||||
expect(result?.kind).toBe("rejected");
|
||||
expect(current.error).toEqual({ title: "Couldn't post the comment", body: "Forbidden", retryable: false });
|
||||
expect(onRejected).toHaveBeenCalledWith(expect.any(ApiError), current.error, expect.objectContaining({ payload: { body: "Not allowed" } }));
|
||||
expect(readPendingSend(STORED_KEY, isPayload)).toBeNull();
|
||||
await advance(60_000);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("restores a pending send after a reload and resends it with its stored key", async () => {
|
||||
writePendingSend(STORED_KEY, { idempotencyKey: "stored-key-123", payload: { body: "From before reload" }, createdAt: 1 });
|
||||
const send = vi.fn().mockResolvedValue({ id: "row-1", body: "From before reload" });
|
||||
render({ send });
|
||||
expect(current.status).toBe("waiting");
|
||||
await advance(1_000);
|
||||
expect(send).toHaveBeenCalledWith({ body: "From before reload" }, "stored-key-123");
|
||||
expect(current.status).toBe("idle");
|
||||
});
|
||||
|
||||
it("neither restores nor resends another account's pending send", async () => {
|
||||
writePendingSend(pendingSendKeyFor(KEY, "user-2"), { idempotencyKey: "other-account-key", payload: { body: "Not mine" }, createdAt: 1 });
|
||||
const send = vi.fn().mockResolvedValue({ id: "row-1", body: "Not mine" });
|
||||
render({ send });
|
||||
expect(current.pending).toBeNull();
|
||||
await advance(5_000);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(readPendingSend(pendingSendKeyFor(KEY, "user-2"), isPayload)?.idempotencyKey).toBe("other-account-key");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a receipt", () => ({ id: "row-1", body: "Before the session" })],
|
||||
["a rejection", () => { throw new ApiError("Title is required", 422, { error: "validation_error" }); }],
|
||||
])("clears a send that moved into the account's storage before %s", async (_label, settle) => {
|
||||
const userKey = pendingSendKeyFor(KEY, "user-1");
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
let finish!: () => void;
|
||||
const send = vi.fn((_payload: Payload, _key: string) => new Promise<{ id: string; body: string }>((resolve, reject) => {
|
||||
finish = () => {
|
||||
try {
|
||||
resolve(settle());
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
}));
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ConnectivityProvider store={store}>
|
||||
<Harness sourceId="test" storageKey={KEY} isPayload={isPayload} send={send} />
|
||||
</ConnectivityProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
// The session is still loading: the send is kept in memory only.
|
||||
let submitted!: Promise<unknown>;
|
||||
act(() => {
|
||||
submitted = current.submit({ body: "Before the session" });
|
||||
});
|
||||
expect(readPendingSend(userKey, isPayload)).toBeNull();
|
||||
|
||||
// The account becomes known mid-request, so the record moves into its key.
|
||||
act(() => {
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
});
|
||||
await flush();
|
||||
expect(readPendingSend(userKey, isPayload)?.idempotencyKey).toBe(send.mock.calls[0]![1]);
|
||||
|
||||
await act(async () => {
|
||||
finish();
|
||||
await submitted;
|
||||
});
|
||||
expect(readPendingSend(userKey, isPayload)).toBeNull();
|
||||
expect(current.pending).toBeNull();
|
||||
client.clear();
|
||||
});
|
||||
|
||||
it("cancel stops resending and returns the payload", async () => {
|
||||
const send = vi.fn().mockRejectedValue(outage());
|
||||
render({ send });
|
||||
await act(async () => {
|
||||
await current.submit({ body: "Keep my text" });
|
||||
});
|
||||
let cancelled: Payload | null = null;
|
||||
act(() => {
|
||||
cancelled = current.cancel();
|
||||
});
|
||||
expect(cancelled).toEqual({ body: "Keep my text" });
|
||||
expect(current.status).toBe("idle");
|
||||
expect(readPendingSend(STORED_KEY, isPayload)).toBeNull();
|
||||
await advance(60_000);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stalls after repeated failures while online and resends on demand", async () => {
|
||||
const send = vi.fn().mockRejectedValue(new ApiError("Bad gateway", 502, null));
|
||||
render({ send });
|
||||
await act(async () => {
|
||||
await current.submit({ body: "Stubborn route" });
|
||||
});
|
||||
// Effects schedule each next resend after React commits, so step through.
|
||||
for (let step = 0; step < 2 * MAX_ONLINE_RESENDS; step += 1) await advance(30_000);
|
||||
expect(send).toHaveBeenCalledTimes(1 + MAX_ONLINE_RESENDS);
|
||||
expect(current.status).toBe("stalled");
|
||||
send.mockResolvedValueOnce({ id: "row-1", body: "Stubborn route" });
|
||||
await act(async () => {
|
||||
current.resendNow();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(current.status).toBe("idle");
|
||||
});
|
||||
|
||||
it("never resends a non-idempotent write on its own", async () => {
|
||||
const send = vi.fn().mockRejectedValueOnce(outage()).mockResolvedValue({ id: "upload-1", body: "file" });
|
||||
render({ send, autoResend: false });
|
||||
await act(async () => {
|
||||
await current.submit({ body: "file" });
|
||||
});
|
||||
await advance(10 * 60_000);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(current.status).toBe("waiting");
|
||||
expect(store.getSnapshot().pendingWrites).toBe(1);
|
||||
await act(async () => {
|
||||
current.resendNow();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(current.status).toBe("idle");
|
||||
});
|
||||
|
||||
it("keeps its resend budget when the caller stops waiting during each resend", async () => {
|
||||
// Like a mutation-backed caller: `active` drops while its own resend is
|
||||
// pending and only comes back a tick after the resend failed.
|
||||
let auto!: AutoResendState;
|
||||
const resend = vi.fn();
|
||||
function FlappingCaller() {
|
||||
const [waiting, setWaiting] = useState(true);
|
||||
auto = useAutoResend({
|
||||
sourceId: "flapping",
|
||||
active: waiting,
|
||||
resend: async () => {
|
||||
resend();
|
||||
setWaiting(false);
|
||||
await Promise.resolve();
|
||||
setTimeout(() => setWaiting(true), 0);
|
||||
return "pending";
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
act(() => {
|
||||
root.render(<ConnectivityProvider store={store}><FlappingCaller /></ConnectivityProvider>);
|
||||
});
|
||||
for (let step = 0; step < 3 * MAX_ONLINE_RESENDS; step += 1) await advance(30_000);
|
||||
expect(resend).toHaveBeenCalledTimes(MAX_ONLINE_RESENDS);
|
||||
expect(auto.stalled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { describeError, type ErrorDescription } from "@/api/errors";
|
||||
import { useConnectivity, useConnectivityStore, usePendingWritesReporter } from "@/lib/connectivity";
|
||||
import {
|
||||
MAX_ONLINE_RESENDS,
|
||||
classifySendFailure,
|
||||
clearPendingSend,
|
||||
pendingSendKeyFor,
|
||||
readPendingSend,
|
||||
resendDelayFor,
|
||||
writePendingSend,
|
||||
type PendingSend,
|
||||
type PendingSendStorage,
|
||||
type SendOutcome,
|
||||
} from "@/lib/pending-send";
|
||||
import { useDraftOwner } from "./useDraftOwner";
|
||||
|
||||
export interface AutoResendOptions {
|
||||
/** Distinct per surface instance; the connection banner counts one pending write per source. */
|
||||
sourceId: string;
|
||||
/** A send is waiting to be resent. */
|
||||
active: boolean;
|
||||
pendingCount?: number;
|
||||
/** Resend the waiting payload with its original idempotency key. */
|
||||
resend: () => Promise<SendOutcome>;
|
||||
/**
|
||||
* False for writes the server does not dedupe: the payload is kept and
|
||||
* counted, but only `resendNow` (a Retry button) sends it again.
|
||||
*/
|
||||
auto?: boolean;
|
||||
}
|
||||
|
||||
export interface AutoResendState {
|
||||
/** A resend is in flight. */
|
||||
resending: boolean;
|
||||
/** Automatic resends stopped while online; the user can still resend now. */
|
||||
stalled: boolean;
|
||||
resendNow: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend a waiting write when the connection allows it: right after the app
|
||||
* recovers from an outage, and with backoff while the app looks online (a
|
||||
* 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 {
|
||||
const store = useConnectivityStore();
|
||||
const online = useConnectivity().status === "online";
|
||||
usePendingWritesReporter(sourceId, active ? pendingCount : 0);
|
||||
|
||||
const resendRef = useRef(resend);
|
||||
resendRef.current = resend;
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const inFlight = useRef(false);
|
||||
/** A resend just failed; the caller has not reported it as waiting again yet. */
|
||||
const settling = useRef(false);
|
||||
const attempts = useRef(0);
|
||||
const wasOnline = useRef(online);
|
||||
const [resending, setResending] = useState(false);
|
||||
const [stalled, setStalled] = useState(false);
|
||||
// Bumped after each failed resend so the schedule effect plans the next one.
|
||||
const [round, setRound] = useState(0);
|
||||
|
||||
const run = useCallback(async () => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
setResending(true);
|
||||
let outcome: SendOutcome;
|
||||
try {
|
||||
outcome = await resendRef.current();
|
||||
} catch (error) {
|
||||
outcome = classifySendFailure(error);
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
if (mounted.current) setResending(false);
|
||||
}
|
||||
if (!mounted.current) return;
|
||||
if (outcome === "pending") {
|
||||
settling.current = true;
|
||||
attempts.current += 1;
|
||||
setRound((value) => value + 1);
|
||||
} else {
|
||||
attempts.current = 0;
|
||||
setStalled(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The budget resets once nothing waits. A caller's `active` can drop during
|
||||
// its own resend (its mutation is pending again) and stay down for a render
|
||||
// after the resend failed; that is not a settled send, and resetting then
|
||||
// would let a failing route retry forever.
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
settling.current = false;
|
||||
return;
|
||||
}
|
||||
if (inFlight.current || settling.current) return;
|
||||
attempts.current = 0;
|
||||
setStalled(false);
|
||||
}, [active, resending]);
|
||||
|
||||
useEffect(() => {
|
||||
const recovered = online && !wasOnline.current;
|
||||
wasOnline.current = online;
|
||||
if (!active || !auto || !online) return;
|
||||
if (recovered) {
|
||||
// An outage ended: start a fresh round and resend right away.
|
||||
attempts.current = 0;
|
||||
setStalled(false);
|
||||
} else if (attempts.current >= MAX_ONLINE_RESENDS) {
|
||||
setStalled(true);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => void run(), recovered ? 0 : resendDelayFor(attempts.current));
|
||||
return () => clearTimeout(timer);
|
||||
}, [active, auto, online, round, run]);
|
||||
|
||||
const resendNow = useCallback(() => {
|
||||
if (!online) store.probeNow();
|
||||
attempts.current = 0;
|
||||
setStalled(false);
|
||||
void run();
|
||||
}, [online, run, store]);
|
||||
|
||||
return { resending, stalled, resendNow };
|
||||
}
|
||||
|
||||
export type DurableSubmitResult<R> =
|
||||
| { kind: "sent"; result: R }
|
||||
| { kind: "pending"; error: unknown }
|
||||
| { kind: "rejected"; error: unknown; description: ErrorDescription };
|
||||
|
||||
export type DurableSubmitStatus =
|
||||
| "idle"
|
||||
/** The first attempt is in flight. */
|
||||
| "sending"
|
||||
/** Kept; it resends with the same key when the connection allows. */
|
||||
| "waiting"
|
||||
/** Kept, but automatic resends stopped; offer Resend now. */
|
||||
| "stalled";
|
||||
|
||||
export interface DurableSubmitOptions<T, R> {
|
||||
sourceId: string;
|
||||
/** Where the pending record lives; null keeps it in memory only. */
|
||||
storageKey: string | null;
|
||||
storage?: PendingSendStorage;
|
||||
/** Validates a stored payload before it is resent. */
|
||||
isPayload: (value: unknown) => value is T;
|
||||
send: (payload: T, idempotencyKey: string) => Promise<R>;
|
||||
onSent?: (result: R, pending: PendingSend<T>) => void;
|
||||
onRejected?: (error: unknown, description: ErrorDescription, pending: PendingSend<T>) => void;
|
||||
/** What the user was doing, for rejection copy: "create the task". */
|
||||
action?: string;
|
||||
/** False for writes the server does not dedupe (see `useAutoResend`). */
|
||||
autoResend?: boolean;
|
||||
}
|
||||
|
||||
export interface DurableSubmit<T, R> {
|
||||
status: DurableSubmitStatus;
|
||||
pending: PendingSend<T> | null;
|
||||
persisted: boolean;
|
||||
/** Copy for the last definitive rejection; cleared by the next submit. */
|
||||
error: ErrorDescription | null;
|
||||
resending: boolean;
|
||||
submit: (payload: T, options?: { idempotencyKey?: string }) => Promise<DurableSubmitResult<R>>;
|
||||
resendNow: () => void;
|
||||
/** Stop resending and forget the record. Returns the payload so its text can go back to the editor. */
|
||||
cancel: () => T | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a keyed write so that it survives outages and reloads. The payload and
|
||||
* key are stored before the request and cleared only on a confirmed receipt or
|
||||
* a definitive rejection; anything in between resends with the same key.
|
||||
*/
|
||||
export function useDurableSubmit<T, R>(options: DurableSubmitOptions<T, R>): DurableSubmit<T, R> {
|
||||
const { sourceId, storage = "local", autoResend = true } = options;
|
||||
// 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.
|
||||
const owner = useDraftOwner();
|
||||
const storageKey = options.storageKey && owner !== undefined ? pendingSendKeyFor(options.storageKey, owner) : null;
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
const load = () => (storageKey ? readPendingSend(storageKey, options.isPayload, storage) : null);
|
||||
const [pending, setPendingState] = useState<PendingSend<T> | null>(load);
|
||||
const [persisted, setPersisted] = useState(pending !== null);
|
||||
const pendingRef = useRef(pending);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<ErrorDescription | null>(null);
|
||||
const inFlight = useRef<string | null>(null);
|
||||
|
||||
const setPending = useCallback((next: PendingSend<T> | null) => {
|
||||
if (!next) setPersisted(false);
|
||||
pendingRef.current = next;
|
||||
setPendingState(next);
|
||||
}, []);
|
||||
|
||||
const loadedKey = useRef(storageKey);
|
||||
useEffect(() => {
|
||||
if (loadedKey.current === storageKey) return;
|
||||
const unsaved = loadedKey.current === null ? pendingRef.current : null;
|
||||
loadedKey.current = storageKey;
|
||||
// A send made before the account was known moves into that account's storage.
|
||||
if (unsaved && storageKey) {
|
||||
setPersisted(writePendingSend(storageKey, unsaved, storage));
|
||||
return;
|
||||
}
|
||||
const restored = storageKey ? readPendingSend(storageKey, optionsRef.current.isPayload, storage) : null;
|
||||
setPending(restored); setPersisted(restored !== null);
|
||||
setError(null);
|
||||
}, [setPending, storage, storageKey]);
|
||||
|
||||
const attempt = useCallback(
|
||||
async (record: PendingSend<T>): Promise<DurableSubmitResult<R>> => {
|
||||
if (inFlight.current === record.idempotencyKey) return { kind: "pending", error: null };
|
||||
inFlight.current = record.idempotencyKey;
|
||||
const key = loadedKey.current;
|
||||
const current = () => pendingRef.current?.idempotencyKey === record.idempotencyKey;
|
||||
// The record may have moved into the account's key during the request
|
||||
// (see above). Only a record with this idempotency key is removed, so
|
||||
// another account's record is never touched.
|
||||
const clearStored = () => {
|
||||
for (const stored of new Set([key, loadedKey.current])) {
|
||||
if (stored) clearPendingSend(stored, record.idempotencyKey, storage);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const result = await optionsRef.current.send(record.payload, record.idempotencyKey);
|
||||
clearStored();
|
||||
if (current()) setPending(null);
|
||||
optionsRef.current.onSent?.(result, record);
|
||||
return { kind: "sent", result };
|
||||
} catch (cause) {
|
||||
if (classifySendFailure(cause) === "pending") return { kind: "pending", error: cause };
|
||||
clearStored();
|
||||
const description = describeError(cause, { action: optionsRef.current.action });
|
||||
if (current()) {
|
||||
setPending(null);
|
||||
setError(description);
|
||||
}
|
||||
optionsRef.current.onRejected?.(cause, description, record);
|
||||
return { kind: "rejected", error: cause, description };
|
||||
} finally {
|
||||
if (inFlight.current === record.idempotencyKey) inFlight.current = null;
|
||||
}
|
||||
},
|
||||
[setPending, storage],
|
||||
);
|
||||
|
||||
const submit = useCallback(
|
||||
async (payload: T, submitOptions: { idempotencyKey?: string } = {}): Promise<DurableSubmitResult<R>> => {
|
||||
if (pendingRef.current) return { kind: "pending", error: null };
|
||||
const record: PendingSend<T> = {
|
||||
idempotencyKey: submitOptions.idempotencyKey ?? crypto.randomUUID(),
|
||||
payload,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
// Persist before the request: a reload or lost response must find it.
|
||||
setPersisted(loadedKey.current ? writePendingSend(loadedKey.current, record, storage) : false);
|
||||
setPending(record);
|
||||
setError(null);
|
||||
setSending(true);
|
||||
try {
|
||||
return await attempt(record);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[attempt, setPending, storage],
|
||||
);
|
||||
|
||||
const auto = useAutoResend({
|
||||
sourceId,
|
||||
active: pending !== null && !sending,
|
||||
auto: autoResend,
|
||||
resend: async () => {
|
||||
const record = pendingRef.current;
|
||||
if (!record) return "sent";
|
||||
const result = await attempt(record);
|
||||
return result.kind;
|
||||
},
|
||||
});
|
||||
|
||||
const cancel = useCallback((): T | null => {
|
||||
const record = pendingRef.current;
|
||||
if (!record) return null;
|
||||
if (loadedKey.current) clearPendingSend(loadedKey.current, record.idempotencyKey, storage);
|
||||
setPending(null);
|
||||
return record.payload;
|
||||
}, [setPending, storage]);
|
||||
|
||||
const status: DurableSubmitStatus = sending
|
||||
? "sending"
|
||||
: pending
|
||||
? auto.stalled ? "stalled" : "waiting"
|
||||
: "idle";
|
||||
|
||||
return { status, pending, persisted, error, resending: auto.resending, submit, resendNow: auto.resendNow, cancel };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useEffect, useRef, type RefObject } from "react";
|
||||
import { loadDraftSubmission, saveDraftSubmission, type ComposerDraftSubmission } from "@/lib/composer-draft";
|
||||
|
||||
interface InFlightSend {
|
||||
draftKey: string;
|
||||
attemptId: string;
|
||||
requestOwner?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A comment sent before the session is known records no owner, so
|
||||
* `replayableRequest` would never resend it. Once the account is known, bind
|
||||
* it to the sends this composer made in the meantime: they went out with this
|
||||
* session. A stored record without an owner may belong to another account in
|
||||
* this browser and stays unreplayable.
|
||||
*
|
||||
* Returns `markUnbound`, to call with each attempt sent while the owner is unknown.
|
||||
*/
|
||||
export function useRequestOwnerBinding({
|
||||
draftKey,
|
||||
owner,
|
||||
uncertainSubmission,
|
||||
setUncertainSubmission,
|
||||
pendingDraftRef,
|
||||
}: {
|
||||
draftKey: string | undefined;
|
||||
owner: string | undefined;
|
||||
uncertainSubmission: ComposerDraftSubmission | null;
|
||||
setUncertainSubmission: (next: ComposerDraftSubmission) => void;
|
||||
pendingDraftRef: RefObject<InFlightSend | null>;
|
||||
}): (attemptId: string) => void {
|
||||
const unbound = useRef(new Set<string>());
|
||||
const markUnbound = useCallback((attemptId: string) => {
|
||||
unbound.current.add(attemptId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (owner === undefined || unbound.current.size === 0) return;
|
||||
const bindStored = (key: string, attemptId: string) => {
|
||||
const stored = loadDraftSubmission(key);
|
||||
if (stored?.attemptId === attemptId && stored.requestOwner === undefined) {
|
||||
saveDraftSubmission(key, { ...stored, requestOwner: owner });
|
||||
}
|
||||
};
|
||||
// Still in flight: later draft backups and a failure keep the owner.
|
||||
const inFlight = pendingDraftRef.current;
|
||||
if (inFlight && inFlight.requestOwner === undefined && unbound.current.has(inFlight.attemptId)) {
|
||||
inFlight.requestOwner = owner;
|
||||
bindStored(inFlight.draftKey, inFlight.attemptId);
|
||||
}
|
||||
const waiting = uncertainSubmission;
|
||||
if (waiting?.request && waiting.requestOwner === undefined && unbound.current.has(waiting.attemptId)) {
|
||||
unbound.current.delete(waiting.attemptId);
|
||||
if (draftKey) bindStored(draftKey, waiting.attemptId);
|
||||
setUncertainSubmission({ ...waiting, requestOwner: owner });
|
||||
}
|
||||
}, [draftKey, owner, pendingDraftRef, setUncertainSubmission, uncertainSubmission]);
|
||||
|
||||
return markUnbound;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { onlineManager, QueryClientProvider, type QueryClient } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { createConnectivityStore, ConnectivityProvider, type ConnectivityStore } from "@/lib/connectivity";
|
||||
import { createAppQueryClient } from "@/lib/query-client";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import {
|
||||
isReplayableIssueUpdate,
|
||||
mapIssuesInQueryData,
|
||||
useUpdateIssueMutation,
|
||||
type UpdateIssueMutation,
|
||||
} from "./useUpdateIssueMutation";
|
||||
|
||||
const mockUpdate = vi.hoisted(() => vi.fn());
|
||||
const mockPushToast = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: { update: mockUpdate } }));
|
||||
vi.mock("@/context/ToastContext", () => ({ useToastActions: () => ({ pushToast: mockPushToast }) }));
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const issue = (overrides: Partial<Issue> = {}) =>
|
||||
({ id: "issue-1", identifier: "PAP-1", companyId: "company-1", status: "todo", priority: "medium", ...overrides }) as Issue;
|
||||
|
||||
describe("mapIssuesInQueryData", () => {
|
||||
const refs = new Set(["PAP-1"]);
|
||||
const done = (value: Issue) => ({ ...value, status: "done" }) as Issue;
|
||||
|
||||
it("updates a single issue, a list, and infinite pages", () => {
|
||||
expect(mapIssuesInQueryData(issue(), refs, done)).toMatchObject({ status: "done" });
|
||||
expect(mapIssuesInQueryData([issue(), issue({ id: "other", identifier: "PAP-2" })], refs, done)).toMatchObject([
|
||||
{ status: "done" },
|
||||
{ status: "todo" },
|
||||
]);
|
||||
const pages = { pages: [[issue()], [issue({ id: "other", identifier: "PAP-2" })]], pageParams: [0, 1] };
|
||||
expect(mapIssuesInQueryData(pages, refs, done)).toMatchObject({ pages: [[{ status: "done" }], [{ status: "todo" }]], pageParams: [0, 1] });
|
||||
});
|
||||
|
||||
it("keeps the same reference when nothing matches", () => {
|
||||
const list = [issue({ id: "other", identifier: "PAP-2" })];
|
||||
expect(mapIssuesInQueryData(list, refs, done)).toBe(list);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isReplayableIssueUpdate", () => {
|
||||
it("replays absolute fields only", () => {
|
||||
expect(isReplayableIssueUpdate({ status: "done", assigneeAgentId: null })).toBe(true);
|
||||
expect(isReplayableIssueUpdate({ comment: "hi" })).toBe(false);
|
||||
expect(isReplayableIssueUpdate({ interrupt: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useUpdateIssueMutation", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let queryClient: QueryClient;
|
||||
let store: ConnectivityStore;
|
||||
let current: UpdateIssueMutation;
|
||||
|
||||
beforeEach(() => {
|
||||
mockUpdate.mockReset();
|
||||
mockPushToast.mockReset();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
store = createConnectivityStore({ probe: async () => ({ reachable: true }), browserOnline: true });
|
||||
queryClient = createAppQueryClient({ connectivity: store });
|
||||
queryClient.setQueryData(queryKeys.auth.session, { user: { id: "account-a" } });
|
||||
queryClient.setQueryData(queryKeys.issues.detail("PAP-1"), issue());
|
||||
queryClient.setQueryData(queryKeys.issues.list("company-1"), [issue()]);
|
||||
queryClient.setQueryData(queryKeys.issues.listByProject("company-1", "project-1"), [issue()]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
store.dispose();
|
||||
queryClient.clear();
|
||||
onlineManager.setOnline(true);
|
||||
});
|
||||
|
||||
function Harness() {
|
||||
current = useUpdateIssueMutation({ companyId: "company-1" });
|
||||
return null;
|
||||
}
|
||||
|
||||
function render() {
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConnectivityProvider store={store}>
|
||||
<Harness />
|
||||
</ConnectivityProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const statusIn = (key: readonly unknown[]) => {
|
||||
const data = queryClient.getQueryData<Issue | Issue[]>(key);
|
||||
return Array.isArray(data) ? data[0]?.status : data?.status;
|
||||
};
|
||||
|
||||
it("updates every cached copy optimistically", async () => {
|
||||
let resolve!: (value: unknown) => void;
|
||||
mockUpdate.mockReturnValue(new Promise((done) => { resolve = done; }));
|
||||
render();
|
||||
act(() => current.mutate({ id: "PAP-1", data: { status: "in_progress" } }));
|
||||
await act(async () => {});
|
||||
expect(statusIn(queryKeys.issues.detail("PAP-1"))).toBe("in_progress");
|
||||
expect(statusIn(queryKeys.issues.list("company-1"))).toBe("in_progress");
|
||||
expect(statusIn(queryKeys.issues.listByProject("company-1", "project-1"))).toBe("in_progress");
|
||||
await act(async () => resolve({ ...issue({ status: "in_progress" }), changes: {} }));
|
||||
});
|
||||
|
||||
it("rolls back and shows readable copy instead of failing silently", async () => {
|
||||
mockUpdate.mockRejectedValue(new ApiError("not_allowed_here", 403, { error: "not_allowed_here" }));
|
||||
render();
|
||||
await act(async () => {
|
||||
current.mutate({ id: "PAP-1", data: { status: "done" } });
|
||||
});
|
||||
await act(async () => {});
|
||||
expect(statusIn(queryKeys.issues.detail("PAP-1"))).toBe("todo");
|
||||
expect(statusIn(queryKeys.issues.listByProject("company-1", "project-1"))).toBe("todo");
|
||||
expect(mockPushToast).toHaveBeenCalledWith({
|
||||
title: "Couldn't update the task",
|
||||
body: "You don’t have permission to do that.",
|
||||
tone: "error",
|
||||
});
|
||||
});
|
||||
|
||||
it("pauses an absolute update during an outage and sends it once on reconnect", async () => {
|
||||
mockUpdate.mockResolvedValue({ ...issue({ priority: "high" }), changes: {} });
|
||||
onlineManager.setOnline(false);
|
||||
render();
|
||||
act(() => current.mutate({ id: "PAP-1", data: { priority: "high" } }));
|
||||
await act(async () => {});
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
expect(statusIn(queryKeys.issues.detail("PAP-1"))).toBe("todo");
|
||||
expect(queryClient.getQueryData<Issue>(queryKeys.issues.detail("PAP-1"))?.priority).toBe("high");
|
||||
await act(async () => {
|
||||
onlineManager.setOnline(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdate).toHaveBeenCalledWith("PAP-1", { priority: "high" });
|
||||
expect(mockPushToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["account-b", "account-a"])("never resumes an old update after sign-out and sign-in as %s", async (nextAccount) => {
|
||||
mockUpdate.mockResolvedValue({ ...issue({ priority: "high" }), changes: {} });
|
||||
onlineManager.setOnline(false);
|
||||
render();
|
||||
let pending!: Promise<unknown>;
|
||||
act(() => { pending = current.mutateAsync({ id: "PAP-1", data: { priority: "high" } }).catch(error => error); });
|
||||
await act(async () => {});
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await queryClient.resetQueries({ queryKey: queryKeys.auth.session });
|
||||
queryClient.setQueryData(queryKeys.auth.session, { user: { id: nextAccount } });
|
||||
onlineManager.setOnline(true);
|
||||
await queryClient.resumePausedMutations();
|
||||
});
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
expect(await pending).toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
|
||||
it("sends a newer update only after an older one still retrying settles", async () => {
|
||||
let finishFirst!: (value: unknown) => void;
|
||||
mockUpdate
|
||||
.mockReturnValueOnce(new Promise((done) => { finishFirst = done; }))
|
||||
.mockResolvedValue({ ...issue({ status: "done" }), changes: {} });
|
||||
render();
|
||||
act(() => current.mutate({ id: "PAP-1", data: { status: "in_progress" } }));
|
||||
act(() => current.mutate({ id: "PAP-1", data: { status: "done" } }));
|
||||
await act(async () => {});
|
||||
// The newer change shows right away but waits to be sent.
|
||||
expect(statusIn(queryKeys.issues.detail("PAP-1"))).toBe("done");
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
finishFirst({ ...issue({ status: "in_progress" }), changes: {} });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(mockUpdate.mock.calls.map((call) => call[1])).toEqual([{ status: "in_progress" }, { status: "done" }]);
|
||||
});
|
||||
|
||||
it("rolls back only the failed update, keeping other tasks' changes", async () => {
|
||||
let failFirst!: (error: unknown) => void;
|
||||
mockUpdate.mockReturnValueOnce(new Promise((_, reject) => { failFirst = reject; }));
|
||||
queryClient.setQueryData(queryKeys.issues.list("company-1"), [issue(), issue({ id: "issue-2", identifier: "PAP-2" })]);
|
||||
render();
|
||||
act(() => current.mutate({ id: "PAP-1", data: { status: "in_progress" } }));
|
||||
await act(async () => {});
|
||||
// Another task changes while this update is still in flight.
|
||||
queryClient.setQueryData<Issue[]>(queryKeys.issues.list("company-1"), (rows) =>
|
||||
rows?.map((row) => (row.id === "issue-2" ? { ...row, status: "done" } : row)));
|
||||
await act(async () => failFirst(new ApiError("not_allowed_here", 403, { error: "not_allowed_here" })));
|
||||
const rows = queryClient.getQueryData<Issue[]>(queryKeys.issues.list("company-1"));
|
||||
expect(rows?.map((row) => row.status)).toEqual(["todo", "done"]);
|
||||
});
|
||||
|
||||
it("keeps a newer edit to the same field when an older update fails", async () => {
|
||||
let failFirst!: (error: unknown) => void;
|
||||
mockUpdate
|
||||
.mockReturnValueOnce(new Promise((_, reject) => { failFirst = reject; }))
|
||||
.mockResolvedValue({ ...issue({ status: "done" }), changes: {} });
|
||||
render();
|
||||
act(() => current.mutate({ id: "PAP-1", data: { status: "in_progress" } }));
|
||||
act(() => current.mutate({ id: "PAP-1", data: { status: "done" } }));
|
||||
await act(async () => {});
|
||||
await act(async () => failFirst(new ApiError("not_allowed_here", 403, { error: "not_allowed_here" })));
|
||||
expect(statusIn(queryKeys.issues.detail("PAP-1"))).toBe("done");
|
||||
});
|
||||
|
||||
it("does not hold an update to another task behind a pending one", async () => {
|
||||
let finishFirst!: (value: unknown) => void;
|
||||
mockUpdate
|
||||
.mockReturnValueOnce(new Promise((done) => { finishFirst = done; }))
|
||||
.mockResolvedValue({ ...issue({ id: "issue-2", identifier: "PAP-2" }), changes: {} });
|
||||
render();
|
||||
act(() => current.mutate({ id: "PAP-1", data: { status: "in_progress" } }));
|
||||
act(() => current.mutate({ id: "PAP-2", data: { status: "done" } }));
|
||||
await act(async () => {});
|
||||
expect(mockUpdate.mock.calls.map((call) => call[0])).toEqual(["PAP-1", "PAP-2"]);
|
||||
await act(async () => finishFirst({ ...issue({ status: "in_progress" }), changes: {} }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
useQueryClient,
|
||||
type MutateOptions,
|
||||
type QueryClient,
|
||||
type QueryKey,
|
||||
} from "@tanstack/react-query";
|
||||
import { useAccountBoundMutation } from "./useAccountBoundMutation";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { describeError, isTransientError } from "@/api/errors";
|
||||
import { issuesApi, type IssueUpdateResponse } from "@/api/issues";
|
||||
import { useToastActions } from "@/context/ToastContext";
|
||||
import { useConnectivityStore } from "@/lib/connectivity";
|
||||
import { applyOptimisticIssueFieldUpdate, matchesIssueRef } from "@/lib/optimistic-issue-comments";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
export interface UpdateIssueVariables {
|
||||
/** Issue id or identifier. */
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** One cached copy of the issue, as it was before and right after the optimistic update. */
|
||||
type IssueRollback = Array<{ queryKey: QueryKey; before: Issue; after: Issue }>;
|
||||
|
||||
export interface UpdateIssueContext {
|
||||
refs: ReadonlySet<string>;
|
||||
rollback: IssueRollback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields that carry more than an absolute value: replaying them could post a
|
||||
* second comment or interrupt twice. Updates with any of these never resend
|
||||
* on their own.
|
||||
*/
|
||||
const NON_REPLAYABLE_FIELDS = ["comment", "interrupt", "attachmentIds", "reopen"] as const;
|
||||
|
||||
/** True when sending this update twice has the same effect as once. */
|
||||
export function isReplayableIssueUpdate(data: Record<string, unknown>): boolean {
|
||||
return NON_REPLAYABLE_FIELDS.every((field) => !Object.prototype.hasOwnProperty.call(data, field));
|
||||
}
|
||||
|
||||
function isIssueLike(value: unknown): value is Issue {
|
||||
return !!value && typeof value === "object" && typeof (value as Issue).id === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply `update` to every issue inside cached query data of the shapes the
|
||||
* app uses: one issue, an issue list, and infinite pages of lists.
|
||||
*/
|
||||
export function mapIssuesInQueryData(data: unknown, refs: ReadonlySet<string>, update: (issue: Issue) => Issue): unknown {
|
||||
if (Array.isArray(data)) {
|
||||
let changed = false;
|
||||
const next = data.map((item) => {
|
||||
if (Array.isArray(item)) {
|
||||
const mapped = mapIssuesInQueryData(item, refs, update);
|
||||
if (mapped !== item) changed = true;
|
||||
return mapped;
|
||||
}
|
||||
if (!isIssueLike(item) || !matchesIssueRef(item, refs)) return item;
|
||||
changed = true;
|
||||
return update(item);
|
||||
});
|
||||
return changed ? next : data;
|
||||
}
|
||||
if (data && typeof data === "object" && Array.isArray((data as { pages?: unknown }).pages)) {
|
||||
const infinite = data as { pages: unknown[] };
|
||||
const pages = mapIssuesInQueryData(infinite.pages, refs, update);
|
||||
return pages === infinite.pages ? data : { ...infinite, pages };
|
||||
}
|
||||
if (isIssueLike(data) && matchesIssueRef(data, refs)) return update(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Optimistically apply `data` to the issue everywhere it is cached; returns what rollback needs. */
|
||||
export function applyOptimisticIssueUpdate(
|
||||
queryClient: QueryClient,
|
||||
companyId: string | null | undefined,
|
||||
refs: ReadonlySet<string>,
|
||||
data: Record<string, unknown>,
|
||||
): IssueRollback {
|
||||
const rollback: IssueRollback = [];
|
||||
const filters = [
|
||||
{ queryKey: ["issues", "detail"] as QueryKey },
|
||||
...(companyId ? [{ queryKey: ["issues", companyId] as QueryKey }] : []),
|
||||
];
|
||||
for (const filter of filters) {
|
||||
for (const [queryKey, cached] of queryClient.getQueriesData({ queryKey: filter.queryKey })) {
|
||||
let copy: { before: Issue; after: Issue } | null = null;
|
||||
const next = mapIssuesInQueryData(cached, refs, (issue) => {
|
||||
const after = applyOptimisticIssueFieldUpdate(issue, data) ?? issue;
|
||||
copy ??= { before: issue, after };
|
||||
return after;
|
||||
});
|
||||
if (next === cached || !copy) continue;
|
||||
rollback.push({ queryKey, ...(copy as { before: Issue; after: Issue }) });
|
||||
queryClient.setQueryData(queryKey, next);
|
||||
}
|
||||
}
|
||||
return rollback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo one failed update: only the fields it changed, only on this issue, and
|
||||
* only where its value still shows. Other tasks' rows and newer edits to the
|
||||
* same field (made since, and possibly still sending) stay as they are.
|
||||
*/
|
||||
export function rollbackOptimisticIssueUpdate(queryClient: QueryClient, refs: ReadonlySet<string>, rollback: IssueRollback) {
|
||||
for (const rows of rollback) {
|
||||
const before = rows.before as unknown as Record<string, unknown>;
|
||||
const after = rows.after as unknown as Record<string, unknown>;
|
||||
const changed = Object.keys(after).filter((field) => !Object.is(after[field], before[field]));
|
||||
if (changed.length === 0) continue;
|
||||
queryClient.setQueryData(rows.queryKey, (current: unknown) =>
|
||||
mapIssuesInQueryData(current, refs, (issue) => {
|
||||
const shown = issue as unknown as Record<string, unknown>;
|
||||
let reverted: Record<string, unknown> | null = null;
|
||||
for (const field of changed) {
|
||||
if (!Object.is(shown[field], after[field])) continue;
|
||||
reverted ??= { ...shown };
|
||||
reverted[field] = before[field];
|
||||
}
|
||||
return (reverted as unknown as Issue | null) ?? issue;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface IssueWriteTurn {
|
||||
/** Settles once the previous update to the same issue has settled. */
|
||||
ready: Promise<void>;
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates to one issue are sent in the order they were made, so a change
|
||||
* still retrying through an outage cannot land after, and overwrite, a newer
|
||||
* one. Updates to different issues never wait for each other.
|
||||
*/
|
||||
const issueWriteTails = new Map<string, Promise<void>>();
|
||||
const issueWriteTurns = new WeakMap<UpdateIssueVariables, IssueWriteTurn>();
|
||||
|
||||
function takeIssueWriteTurn(key: string): IssueWriteTurn {
|
||||
const ready = issueWriteTails.get(key) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const settled = new Promise<void>((resolve) => { release = resolve; });
|
||||
const tail = ready.then(() => settled);
|
||||
issueWriteTails.set(key, tail);
|
||||
void tail.then(() => {
|
||||
if (issueWriteTails.get(key) === tail) issueWriteTails.delete(key);
|
||||
});
|
||||
return { ready, release };
|
||||
}
|
||||
|
||||
export interface UseUpdateIssueMutationOptions {
|
||||
companyId: string | null | undefined;
|
||||
/** Query keys this surface lists issues under; refreshed after every update. */
|
||||
invalidateKeys?: QueryKey[];
|
||||
onSuccess?: (result: IssueUpdateResponse, variables: UpdateIssueVariables) => void;
|
||||
onSettled?: () => void;
|
||||
/** What the user was doing, for error copy. */
|
||||
action?: string;
|
||||
}
|
||||
|
||||
export interface UpdateIssueMutation {
|
||||
mutate: (
|
||||
variables: UpdateIssueVariables,
|
||||
options?: MutateOptions<IssueUpdateResponse, Error, UpdateIssueVariables, UpdateIssueContext>,
|
||||
) => void;
|
||||
mutateAsync: (
|
||||
variables: UpdateIssueVariables,
|
||||
options?: MutateOptions<IssueUpdateResponse, Error, UpdateIssueVariables, UpdateIssueContext>,
|
||||
) => Promise<IssueUpdateResponse>;
|
||||
isPending: boolean;
|
||||
variables: UpdateIssueVariables | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one issue-update mutation: optimistic update of every cached copy,
|
||||
* rollback and readable toast on failure, and `meta.replay = "idempotent"`
|
||||
* for absolute-field updates so they pause through an outage and resend on
|
||||
* reconnect instead of failing.
|
||||
*/
|
||||
export function useUpdateIssueMutation(options: UseUpdateIssueMutationOptions): UpdateIssueMutation {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
const connectivity = useConnectivityStore();
|
||||
const { companyId, invalidateKeys, onSuccess, onSettled, action = "update the task" } = options;
|
||||
|
||||
const mutationOptions = {
|
||||
waitForTurn: (variables: UpdateIssueVariables) => issueWriteTurns.get(variables)?.ready,
|
||||
mutationFn: (variables: UpdateIssueVariables) => issuesApi.update(variables.id, variables.data),
|
||||
onMutate: async ({ id, data }: UpdateIssueVariables): Promise<UpdateIssueContext> => {
|
||||
const refs = new Set([id]);
|
||||
const cached = queryClient.getQueryData<Issue>(queryKeys.issues.detail(id));
|
||||
if (cached?.id) refs.add(cached.id);
|
||||
if (cached?.identifier) refs.add(cached.identifier);
|
||||
// Stop in-flight reads of this issue and its lists from overwriting the
|
||||
// optimistic value; other issues' reads keep going.
|
||||
await Promise.all([
|
||||
...[...refs].map((ref) => queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(ref) })),
|
||||
...(companyId ? [queryClient.cancelQueries({ queryKey: queryKeys.issues.list(companyId) })] : []),
|
||||
]);
|
||||
return { refs, rollback: applyOptimisticIssueUpdate(queryClient, companyId, refs, data) };
|
||||
},
|
||||
onSuccess: (result: IssueUpdateResponse, variables: UpdateIssueVariables) => {
|
||||
onSuccess?.(result, variables);
|
||||
},
|
||||
onError: (error: Error, _variables: UpdateIssueVariables, context: UpdateIssueContext | undefined) => {
|
||||
if (context) rollbackOptimisticIssueUpdate(queryClient, context.refs, context.rollback);
|
||||
// While the connection banner is up it already explains an outage.
|
||||
if (isTransientError(error) && connectivity.getSnapshot().status !== "online") return;
|
||||
const { title, body } = describeError(error, { action });
|
||||
pushToast({ title, body, tone: "error" });
|
||||
},
|
||||
onFinished: (variables: UpdateIssueVariables) => { issueWriteTurns.get(variables)?.release(); },
|
||||
onSettled: (_result: unknown, _error: unknown, variables: UpdateIssueVariables) => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(variables.id) });
|
||||
for (const queryKey of invalidateKeys ?? (companyId ? [queryKeys.issues.list(companyId)] : [])) {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
}
|
||||
onSettled?.();
|
||||
},
|
||||
};
|
||||
// Two observers, because `meta` is fixed per mutation: absolute updates may
|
||||
// replay across an outage, anything else fails fast.
|
||||
const replayable = useAccountBoundMutation({ ...mutationOptions, meta: { replay: "idempotent" } });
|
||||
const direct = useAccountBoundMutation(mutationOptions);
|
||||
|
||||
// Stable callbacks: callers list `mutate` in effect and callback deps.
|
||||
const replayableRef = useRef(replayable);
|
||||
replayableRef.current = replayable;
|
||||
const directRef = useRef(direct);
|
||||
directRef.current = direct;
|
||||
const companyIdRef = useRef(companyId);
|
||||
companyIdRef.current = companyId;
|
||||
const queue = (variables: UpdateIssueVariables) => {
|
||||
// A copy, so each call gets its own turn even if a caller reuses variables.
|
||||
const queued = { ...variables };
|
||||
const cached = queryClient.getQueryData<Issue>(queryKeys.issues.detail(variables.id));
|
||||
issueWriteTurns.set(queued, takeIssueWriteTurn(`${companyIdRef.current ?? "none"}:${cached?.id ?? variables.id}`));
|
||||
const mutation = isReplayableIssueUpdate(queued.data) ? replayableRef.current : directRef.current;
|
||||
return { mutation, queued };
|
||||
};
|
||||
const mutate = useCallback<UpdateIssueMutation["mutate"]>((variables, mutateOptions) => {
|
||||
const { mutation, queued } = queue(variables);
|
||||
mutation.mutate(queued, mutateOptions);
|
||||
}, []);
|
||||
const mutateAsync = useCallback<UpdateIssueMutation["mutateAsync"]>((variables, mutateOptions) => {
|
||||
const { mutation, queued } = queue(variables);
|
||||
return mutation.mutateAsync(queued, mutateOptions);
|
||||
}, []);
|
||||
const latest = (replayable.submittedAt ?? 0) >= (direct.submittedAt ?? 0) ? replayable : direct;
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
mutate,
|
||||
mutateAsync,
|
||||
isPending: replayable.isPending || direct.isPending,
|
||||
variables: latest.variables,
|
||||
}),
|
||||
[direct.isPending, latest.variables, mutate, mutateAsync, replayable.isPending],
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
saveDraftSubmission,
|
||||
clearDraftSubmission,
|
||||
settleDraftSubmission,
|
||||
replayableRequest,
|
||||
} from "./composer-draft";
|
||||
|
||||
describe("task draft upload receipts", () => {
|
||||
@@ -24,7 +25,7 @@ describe("task draft upload receipts", () => {
|
||||
inline: false,
|
||||
contentPath: `/api/attachments/${id}/content`,
|
||||
};
|
||||
beforeEach(() => localStorage.clear());
|
||||
beforeEach(() => { localStorage.clear(); sessionStorage.clear(); });
|
||||
it("persists a tab recovery without touching shared text, receipts, or pending submission", () => {
|
||||
sessionStorage.clear();
|
||||
saveDraft(key, "Other tab text");
|
||||
@@ -82,6 +83,19 @@ describe("task draft upload receipts", () => {
|
||||
expect(loadDraftAttachments(key)).toEqual([nextReceipt]);
|
||||
expect(loadDraftSubmission(key)).toBeNull();
|
||||
});
|
||||
it.each([
|
||||
{ label: "long task comment", draftKey: key, body: "a".repeat(300_000) },
|
||||
{ label: "escaped task comment", draftKey: key, body: "\n".repeat(150_000) },
|
||||
{ label: "long chat comment", draftKey: "paperclip:agent-chat-draft:large", body: "a".repeat(300_000) },
|
||||
{ label: "escaped chat comment", draftKey: "paperclip:agent-chat-draft:large", body: "\n".repeat(150_000) },
|
||||
])("restores a large accepted request with its original key ($label)", ({ draftKey, body }) => {
|
||||
const request = { body };
|
||||
saveDraftSubmission(draftKey, { attemptId: id, reviewed: false, request, requestOwner: "user-1" });
|
||||
const loaded = loadDraftSubmission(draftKey);
|
||||
expect(loaded?.attemptId).toBe(id);
|
||||
expect(loaded?.request?.body === body).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps chat drafts and pending submission fences within the current tab", () => {
|
||||
sessionStorage.clear();
|
||||
const chatKey = "paperclip:agent-chat-draft:company:user:agent";
|
||||
@@ -156,3 +170,35 @@ describe("task draft upload receipts", () => {
|
||||
expect(loadDraftAttachments(key)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replayable comment requests", () => {
|
||||
const key = "paperclip:issue-comment-draft:task-two";
|
||||
const id = "1c6ad4b0-7f55-4c1e-9d6a-2a3d2b1f4e10";
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("replays a retained request only for the account that made it", () => {
|
||||
saveDraft(key, "Sent during the outage");
|
||||
saveDraftSubmission(key, { attemptId: id, reviewed: false, request: { body: "Sent during the outage" }, requestOwner: "user-1" });
|
||||
const stored = loadDraftSubmission(key);
|
||||
expect(stored?.requestOwner).toBe("user-1");
|
||||
expect(replayableRequest(stored, "user-1")).toEqual({ body: "Sent during the outage" });
|
||||
expect(replayableRequest(stored, "user-2")).toBeUndefined();
|
||||
expect(replayableRequest(stored, undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ reassignment: { assigneeAgentId: "old-agent", assigneeUserId: null } },
|
||||
{ runSettings: { model: "old-model" } },
|
||||
{ reopen: true },
|
||||
])("does not replay a composite mutation that can overwrite newer task changes: %j", (changes) => {
|
||||
saveDraftSubmission(key, { attemptId: id, reviewed: false, request: { body: "Keep the comment", ...changes }, requestOwner: "user-1" });
|
||||
expect(replayableRequest(loadDraftSubmission(key), "user-1")).toBeUndefined();
|
||||
expect(loadDraftSubmission(key)?.request?.body).toBe("Keep the comment");
|
||||
});
|
||||
|
||||
it("never replays a request with no recorded account", () => {
|
||||
saveDraft(key, "Older record");
|
||||
saveDraftSubmission(key, { attemptId: id, reviewed: false, request: { body: "Older record" } });
|
||||
expect(replayableRequest(loadDraftSubmission(key), "user-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -93,12 +93,71 @@ export function clearDraft(draftKey: string, attemptId?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact comment request, kept so a send that failed transiently or lost
|
||||
* its receipt can be resent with the same `attemptId`. The server dedupes by
|
||||
* that ID and rejects it for different content, so the body must be the one
|
||||
* originally sent, not the editor text.
|
||||
*/
|
||||
export interface ComposerSendRequest {
|
||||
body: string;
|
||||
reopen?: boolean;
|
||||
reassignment?: { assigneeAgentId: string | null; assigneeUserId: string | null };
|
||||
attachmentIds?: string[];
|
||||
/** Composer run settings, passed back unchanged. */
|
||||
runSettings?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ComposerDraftSubmission {
|
||||
attemptId: string;
|
||||
reviewed: boolean;
|
||||
/** Start of text typed after the submitted body in a restored uncertain draft. */
|
||||
nextDraftOffset?: number;
|
||||
submittedAttachmentIds?: string[];
|
||||
/** Present when the send can be replayed; older records have none. */
|
||||
request?: ComposerSendRequest;
|
||||
/** The account that made `request`; only that account may replay it (see `useDraftOwner`). */
|
||||
requestOwner?: string;
|
||||
}
|
||||
|
||||
/** The retained request, only for the account that made it: another account in this browser never replays it. */
|
||||
export function replayableRequest(
|
||||
submission: ComposerDraftSubmission | null | undefined,
|
||||
owner: string | undefined,
|
||||
): ComposerSendRequest | undefined {
|
||||
const request = submission?.request;
|
||||
if (owner === undefined || submission?.requestOwner !== owner || !request) return undefined;
|
||||
if (request.reassignment !== undefined || request.runSettings !== undefined || request.reopen === true) return undefined;
|
||||
return request;
|
||||
}
|
||||
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return value === null || (typeof value === "string" && value.length <= 128);
|
||||
}
|
||||
|
||||
export function isComposerSendRequest(value: unknown): value is ComposerSendRequest {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const row = value as Record<string, unknown>;
|
||||
if (!Object.keys(row).every((key) => ["body", "reopen", "reassignment", "attachmentIds", "runSettings"].includes(key))) return false;
|
||||
if (typeof row.body !== "string") return false;
|
||||
if (row.reopen !== undefined && typeof row.reopen !== "boolean") return false;
|
||||
if (row.reassignment !== undefined) {
|
||||
const reassignment = row.reassignment as Record<string, unknown> | null;
|
||||
if (
|
||||
!reassignment || typeof reassignment !== "object" || Array.isArray(reassignment) ||
|
||||
!isNullableString(reassignment.assigneeAgentId) || !isNullableString(reassignment.assigneeUserId)
|
||||
) return false;
|
||||
}
|
||||
if (
|
||||
row.attachmentIds !== undefined &&
|
||||
(!Array.isArray(row.attachmentIds) || row.attachmentIds.length > 256 ||
|
||||
!row.attachmentIds.every((id) => typeof id === "string" && id.length <= 128))
|
||||
) return false;
|
||||
if (
|
||||
row.runSettings !== undefined &&
|
||||
(!row.runSettings || typeof row.runSettings !== "object" || Array.isArray(row.runSettings))
|
||||
) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Retained client request ID. It is not delivery proof until a matching
|
||||
@@ -108,19 +167,23 @@ export function loadDraftSubmission(
|
||||
): ComposerDraftSubmission | null {
|
||||
try {
|
||||
const raw = draftStorage(draftKey).getItem(`${draftKey}:submission:v1`);
|
||||
if (!raw || raw.length > 16_384) return null;
|
||||
if (!raw) return null;
|
||||
const record = JSON.parse(raw);
|
||||
return record?.version === 1 &&
|
||||
record.draftKey === draftKey &&
|
||||
Object.keys(record).every((key) => ["version", "draftKey", "attemptId", "reviewed", "nextDraftOffset", "submittedAttachmentIds"].includes(key)) &&
|
||||
Object.keys(record).every((key) => ["version", "draftKey", "attemptId", "reviewed", "nextDraftOffset", "submittedAttachmentIds", "request", "requestOwner"].includes(key)) &&
|
||||
typeof record.attemptId === "string" &&
|
||||
/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(record.attemptId) &&
|
||||
typeof record.reviewed === "boolean" &&
|
||||
(record.nextDraftOffset === undefined || (Number.isSafeInteger(record.nextDraftOffset) && record.nextDraftOffset >= 0)) &&
|
||||
(record.submittedAttachmentIds === undefined || (Array.isArray(record.submittedAttachmentIds) && record.submittedAttachmentIds.length <= 256 && record.submittedAttachmentIds.every((id: unknown) => typeof id === "string" && id.length <= 128)))
|
||||
(record.submittedAttachmentIds === undefined || (Array.isArray(record.submittedAttachmentIds) && record.submittedAttachmentIds.length <= 256 && record.submittedAttachmentIds.every((id: unknown) => typeof id === "string" && id.length <= 128))) &&
|
||||
(record.request === undefined || isComposerSendRequest(record.request)) &&
|
||||
(record.requestOwner === undefined || (typeof record.requestOwner === "string" && record.requestOwner.length <= 256))
|
||||
? { attemptId: record.attemptId, reviewed: record.reviewed,
|
||||
...(record.nextDraftOffset !== undefined ? { nextDraftOffset: record.nextDraftOffset } : {}),
|
||||
...(record.submittedAttachmentIds !== undefined ? { submittedAttachmentIds: record.submittedAttachmentIds } : {}),
|
||||
...(record.request !== undefined ? { request: record.request } : {}),
|
||||
...(record.requestOwner !== undefined ? { requestOwner: record.requestOwner } : {}),
|
||||
}
|
||||
: null;
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { ApiUnavailableError } from "@/api/response";
|
||||
import { CommentSubmissionUnknownError } from "./comment-submit-result";
|
||||
import { classifySendFailure, clearPendingSend, readPendingSend, writePendingSend, PendingSendAccountChangedError } from "./pending-send";
|
||||
|
||||
const isText = (value: unknown): value is string => typeof value === "string";
|
||||
|
||||
describe("classifySendFailure", () => {
|
||||
it.each([
|
||||
["an interrupted account-bound request", new PendingSendAccountChangedError()],
|
||||
["a lost comment receipt", new CommentSubmissionUnknownError()],
|
||||
["a gateway outage with a 4xx status", new ApiError("x", 400, { error: "tenant_app_unavailable" })],
|
||||
["a 503", new ApiError("x", 503, null)],
|
||||
["a 500 that may have committed", new ApiError("x", 500, null)],
|
||||
["a 429", new ApiError("x", 429, null)],
|
||||
["a network drop", new TypeError("Failed to fetch")],
|
||||
["an HTML proxy page", new ApiUnavailableError(502)],
|
||||
])("keeps %s pending", (_label, error) => {
|
||||
expect(classifySendFailure(error)).toBe("pending");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a 403", new ApiError("Forbidden", 403, null)],
|
||||
["a 409", new ApiError("Conflict", 409, null)],
|
||||
["a 422", new ApiError("Invalid", 422, null)],
|
||||
["a plain error from the caller", new Error("try again")],
|
||||
])("rejects %s", (_label, error) => {
|
||||
expect(classifySendFailure(error)).toBe("rejected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pending-send records", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("round-trips a record and validates its payload", () => {
|
||||
expect(writePendingSend("k", { idempotencyKey: "key-12345", payload: "body", createdAt: 5 })).toBe(true);
|
||||
expect(readPendingSend("k", isText)).toEqual({ idempotencyKey: "key-12345", payload: "body", createdAt: 5 });
|
||||
expect(readPendingSend("k", (value): value is number => typeof value === "number")).toBeNull();
|
||||
expect(readPendingSend("other", isText)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["local", "session"] as const)("restores a successfully persisted large request from %s storage", (storage) => {
|
||||
const payload = "x".repeat(300_000);
|
||||
const record = { idempotencyKey: "large-original-request", payload, createdAt: 5 };
|
||||
expect(writePendingSend("large", record, storage)).toBe(true);
|
||||
const restored = readPendingSend("large", isText, storage);
|
||||
expect(restored?.idempotencyKey).toBe(record.idempotencyKey);
|
||||
expect(restored?.payload === payload).toBe(true);
|
||||
clearPendingSend("large", record.idempotencyKey, storage);
|
||||
});
|
||||
|
||||
it("clears only the matching send when given its key", () => {
|
||||
writePendingSend("k", { idempotencyKey: "newer-key-1", payload: "newer", createdAt: 2 });
|
||||
clearPendingSend("k", "older-key-1");
|
||||
expect(readPendingSend("k", isText)?.payload).toBe("newer");
|
||||
clearPendingSend("k", "newer-key-1");
|
||||
expect(readPendingSend("k", isText)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Durable sends: writes that must not lose the user's text across a deploy,
|
||||
* a restart, or a network drop.
|
||||
*
|
||||
* The pattern (generalized from `components/chat/board-send-draft.ts`):
|
||||
*
|
||||
* 1. Persist the payload and its idempotency key **before** the request.
|
||||
* 2. Send.
|
||||
* 3. On a transient or unconfirmed failure, keep the record and resend it with
|
||||
* the **same key** when the connection returns. The server dedupes by that
|
||||
* key (`clientRequestId` for comments, `idempotencyKey` for issue create and
|
||||
* board publishes), so a resend after a lost response cannot post twice.
|
||||
* 4. Clear the record only after a confirmed receipt.
|
||||
* 5. On a definitive rejection (a 4xx), clear the record and give the text
|
||||
* back to the editor with `describeError` copy.
|
||||
*
|
||||
* Only writes the server dedupes, or that set absolute values, may resend on
|
||||
* their own. Uploads and other non-idempotent POSTs keep the input and offer
|
||||
* Retry, but never resend by themselves.
|
||||
*/
|
||||
|
||||
import { classifyError, errorStatus } from "@/api/errors";
|
||||
import { CommentSubmissionUnknownError } from "./comment-submit-result";
|
||||
|
||||
/** What to do after a send failed. */
|
||||
export type SendFailure =
|
||||
/** Not delivered, or not known to be delivered: keep it and resend with the same key. */
|
||||
| "pending"
|
||||
/** The server refused it; resending the same request cannot help. */
|
||||
| "rejected";
|
||||
|
||||
export type SendOutcome = "sent" | SendFailure;
|
||||
|
||||
/**
|
||||
* Classify a failed idempotent send. "Pending" covers outages (network drop,
|
||||
* proxy 5xx, gateway codes, 429) and lost receipts (any 5xx may have committed
|
||||
* before failing). Everything else, including a plain `Error` thrown by the
|
||||
* caller, is a definitive answer.
|
||||
*/
|
||||
export class PendingSendAccountChangedError extends Error {
|
||||
constructor() {
|
||||
super("Your account changed. The original request is kept for its owner.");
|
||||
this.name = "PendingSendAccountChangedError";
|
||||
}
|
||||
}
|
||||
|
||||
export function classifySendFailure(error: unknown): SendFailure {
|
||||
if (error instanceof PendingSendAccountChangedError) return "pending";
|
||||
if (error instanceof CommentSubmissionUnknownError) return "pending";
|
||||
const kind = classifyError(error);
|
||||
if (kind === "transient") return "pending";
|
||||
if (kind === "unknown" && (errorStatus(error) ?? 0) >= 500) return "pending";
|
||||
return "rejected";
|
||||
}
|
||||
|
||||
// --- Persisted records ------------------------------------------------------------
|
||||
|
||||
export interface PendingSend<T> {
|
||||
idempotencyKey: string;
|
||||
payload: T;
|
||||
/** Epoch ms of the first attempt. */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const RECORD_VERSION = 1;
|
||||
|
||||
export type PendingSendStorage = "local" | "session";
|
||||
|
||||
/** Pending sends are stored per signed-in account (see `useDraftOwner`). */
|
||||
export function pendingSendKeyFor(key: string, owner: string): string {
|
||||
return `${key}:${owner}`;
|
||||
}
|
||||
|
||||
function storageFor(kind: PendingSendStorage): Storage | null {
|
||||
try {
|
||||
return kind === "session" ? sessionStorage : localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readPendingSend<T>(
|
||||
key: string,
|
||||
isPayload: (value: unknown) => value is T,
|
||||
storage: PendingSendStorage = "local",
|
||||
): PendingSend<T> | null {
|
||||
try {
|
||||
const raw = storageFor(storage)?.getItem(key);
|
||||
if (!raw) return null;
|
||||
const record = JSON.parse(raw) as Record<string, unknown> | null;
|
||||
if (
|
||||
!record ||
|
||||
record.version !== RECORD_VERSION ||
|
||||
record.key !== key ||
|
||||
typeof record.idempotencyKey !== "string" ||
|
||||
record.idempotencyKey.length < 8 ||
|
||||
record.idempotencyKey.length > 255 ||
|
||||
typeof record.createdAt !== "number" ||
|
||||
!isPayload(record.payload)
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
idempotencyKey: record.idempotencyKey,
|
||||
payload: record.payload,
|
||||
createdAt: record.createdAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns false when browser storage refused the write; the caller keeps the record in memory. */
|
||||
export function writePendingSend<T>(
|
||||
key: string,
|
||||
record: PendingSend<T>,
|
||||
storage: PendingSendStorage = "local",
|
||||
): boolean {
|
||||
try {
|
||||
const target = storageFor(storage);
|
||||
if (!target) return false;
|
||||
target.setItem(key, JSON.stringify({ version: RECORD_VERSION, key, ...record }));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a record. With `idempotencyKey`, only that exact send is cleared: a
|
||||
* late response for an older send must not drop a newer one.
|
||||
*/
|
||||
export function clearPendingSend(
|
||||
key: string,
|
||||
idempotencyKey?: string,
|
||||
storage: PendingSendStorage = "local",
|
||||
): void {
|
||||
try {
|
||||
const target = storageFor(storage);
|
||||
if (!target) return;
|
||||
if (idempotencyKey !== undefined) {
|
||||
const raw = target.getItem(key);
|
||||
if (raw) {
|
||||
const record = JSON.parse(raw) as { idempotencyKey?: unknown } | null;
|
||||
if (record?.idempotencyKey !== idempotencyKey) return;
|
||||
}
|
||||
}
|
||||
target.removeItem(key);
|
||||
} catch {
|
||||
// Unavailable storage holds nothing to clear.
|
||||
}
|
||||
}
|
||||
|
||||
// --- Resend schedule ------------------------------------------------------------------
|
||||
|
||||
/** Delay before each automatic resend while the app is online; the last value repeats. */
|
||||
export const RESEND_BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 20_000, 30_000] as const;
|
||||
/**
|
||||
* Automatic resends while the app looks online before waiting for the user.
|
||||
* A route that keeps failing while health is fine is not an outage; polling it
|
||||
* forever helps nobody. Recovering from an outage starts a fresh round.
|
||||
*/
|
||||
export const MAX_ONLINE_RESENDS = 8;
|
||||
|
||||
export function resendDelayFor(attempt: number): number {
|
||||
return RESEND_BACKOFF_MS[Math.min(Math.max(attempt, 0), RESEND_BACKOFF_MS.length - 1)];
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describeError } from "../api/errors";
|
||||
import { AgentIdentity } from "@/components/AgentIdentity";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "@/lib/router";
|
||||
@@ -92,7 +93,7 @@ export function ApprovalDetail() {
|
||||
refresh();
|
||||
navigate(`/approvals/${approvalId}?resolved=approved`, { replace: true });
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : "Approve failed"),
|
||||
onError: (err) => setError(describeError(err, { action: "approve this request" }).body),
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
@@ -101,7 +102,7 @@ export function ApprovalDetail() {
|
||||
setError(null);
|
||||
refresh();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : "Reject failed"),
|
||||
onError: (err) => setError(describeError(err, { action: "reject this request" }).body),
|
||||
});
|
||||
|
||||
const revisionMutation = useMutation({
|
||||
@@ -110,7 +111,7 @@ export function ApprovalDetail() {
|
||||
setError(null);
|
||||
refresh();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : "Revision request failed"),
|
||||
onError: (err) => setError(describeError(err, { action: "request a revision" }).body),
|
||||
});
|
||||
|
||||
const resubmitMutation = useMutation({
|
||||
@@ -119,7 +120,7 @@ export function ApprovalDetail() {
|
||||
setError(null);
|
||||
refresh();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : "Resubmit failed"),
|
||||
onError: (err) => setError(describeError(err, { action: "resubmit this request" }).body),
|
||||
});
|
||||
|
||||
const addCommentMutation = useMutation({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describeError } from "../api/errors";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useLocation } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -50,7 +51,7 @@ export function Approvals() {
|
||||
navigate(`/approvals/${id}?resolved=approved`);
|
||||
},
|
||||
onError: (err) => {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to approve");
|
||||
setActionError(describeError(err, { action: "approve this request" }).body);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -61,7 +62,7 @@ export function Approvals() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||
},
|
||||
onError: (err) => {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to reject");
|
||||
setActionError(describeError(err, { action: "reject this request" }).body);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
routineHasWorkspaceSpecificVariables,
|
||||
sortWorkspaceRoutinesByName,
|
||||
} from "../lib/workspace-routines";
|
||||
import { useUpdateIssueMutation } from "../hooks/useUpdateIssueMutation";
|
||||
|
||||
type WorkspaceFormState = {
|
||||
name: string;
|
||||
@@ -551,15 +552,13 @@ function ExecutionWorkspaceIssuesList({
|
||||
|
||||
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns, issues), [issues, liveRuns]);
|
||||
|
||||
const updateIssue = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => issuesApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByExecutionWorkspace(companyId, workspace.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
if (project?.id) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, project.id) });
|
||||
}
|
||||
},
|
||||
const updateIssue = useUpdateIssueMutation({
|
||||
companyId,
|
||||
invalidateKeys: [
|
||||
queryKeys.issues.listByExecutionWorkspace(companyId, workspace.id),
|
||||
queryKeys.issues.list(companyId),
|
||||
...(project?.id ? [queryKeys.issues.listByProject(companyId, project.id)] : []),
|
||||
],
|
||||
});
|
||||
|
||||
const projectOptions = useMemo(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describeError } from "../api/errors";
|
||||
import { useIssueRowDetails } from "../hooks/useIssueRowDetails";
|
||||
import { IssueRowDetailsNotice } from "../components/IssueRowDetailsNotice";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -1822,7 +1823,7 @@ function StreamlinedInbox({
|
||||
navigate(`/approvals/${id}?resolved=approved`);
|
||||
},
|
||||
onError: (err) => {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to approve");
|
||||
setActionError(describeError(err, { action: "approve this request" }).body);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1833,7 +1834,7 @@ function StreamlinedInbox({
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||
},
|
||||
onError: (err) => {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to reject");
|
||||
setActionError(describeError(err, { action: "reject this request" }).body);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1526,7 +1526,7 @@ describe("IssueDetail", () => {
|
||||
await waitForAssertion(() => expect(mockIssuesApi.update).toHaveBeenCalledTimes(1));
|
||||
expect(getLastProjectId(issue.companyId)).toBe("original-project");
|
||||
await act(async () => failedUpdate.reject(new Error("Project save failed")));
|
||||
await waitForAssertion(() => expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Task update failed" })));
|
||||
await waitForAssertion(() => expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Couldn't update the task" })));
|
||||
expect(getLastProjectId(issue.companyId)).toBe("original-project");
|
||||
await act(async () => properties.onUpdate({ projectId }));
|
||||
await waitForAssertion(() => expect(mockIssuesApi.update).toHaveBeenCalledTimes(2));
|
||||
@@ -1536,6 +1536,31 @@ describe("IssueDetail", () => {
|
||||
expect(getLastProjectId("company-2")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not post after the account changes while comment queries cancel", async () => {
|
||||
mockAuthApi.getSession.mockResolvedValue({ user: { id: "account-a" } });
|
||||
mockIssuesApi.addComment.mockResolvedValue(createIssueComment({ body: "Account A retained text" }));
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
await act(async () => renderIssueView());
|
||||
await flushReact();
|
||||
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as { onAdd: (body: string) => Promise<void> };
|
||||
let release!: () => void;
|
||||
const cancellation = new Promise<void>(resolve => { release = resolve; });
|
||||
const cancel = vi.spyOn(queryClient, "cancelQueries").mockReturnValue(cancellation);
|
||||
let pending!: Promise<unknown>;
|
||||
try {
|
||||
await act(async () => { pending = props.onAdd("Account A retained text").catch(error => error); });
|
||||
await waitForAssertion(() => expect(cancel).toHaveBeenCalledTimes(2));
|
||||
await act(async () => {
|
||||
queryClient.setQueryData(queryKeys.auth.session, { user: { id: "account-b" } });
|
||||
release();
|
||||
});
|
||||
const result = await pending;
|
||||
expect(mockIssuesApi.addComment).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ name: "AbortError" });
|
||||
await waitForAssertion(() => expect((mockIssueChatThreadRender.mock.calls.at(-1)?.[0]?.comments ?? []).some((comment: IssueComment) => comment.body === "Account A retained text")).toBe(false));
|
||||
} finally { release(); cancel.mockRestore(); }
|
||||
});
|
||||
|
||||
it("keeps an existing conversation on its agent-addressed route", async () => {
|
||||
const agent = createAgent();
|
||||
const canonical = createIssue({ conversationAgentId: agent.id, conversationUserId: "user-1", conversationState: "waiting", status: "in_review" });
|
||||
|
||||
+50
-131
@@ -1,3 +1,4 @@
|
||||
import { useAccountBoundMutation } from "@/hooks/useAccountBoundMutation";
|
||||
import { mergeWindowComments, useIssueHistoryWindow } from "../hooks/useIssueHistoryWindow";
|
||||
import type { IssueQueuedCommentQueue } from "@paperclipai/shared";
|
||||
import { useTaskRunLiveUpdates } from "../context/LiveUpdatesProvider";
|
||||
@@ -65,7 +66,9 @@ import {
|
||||
} from "@/hooks/useSharedPolling";
|
||||
import { ApiError } from "../api/client";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { CommentSubmissionUnknownError } from "../lib/comment-submit-result";
|
||||
import { classifySendFailure } from "../lib/pending-send";
|
||||
import { useUpdateIssueMutation } from "../hooks/useUpdateIssueMutation";
|
||||
import { interactionResolutionErrorMessage } from "../lib/interaction-resolution-error";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { activityApi, type RunForIssue } from "../api/activity";
|
||||
import {
|
||||
@@ -159,8 +162,6 @@ import {
|
||||
resolveInboxQuickArchiveKeyAction,
|
||||
} from "../lib/keyboardShortcuts";
|
||||
import {
|
||||
applyOptimisticIssueFieldUpdate,
|
||||
applyOptimisticIssueFieldUpdateToCollection,
|
||||
applyOptimisticIssueCommentUpdate,
|
||||
applyLocalQueuedIssueCommentState,
|
||||
createOptimisticIssueComment,
|
||||
@@ -1323,7 +1324,6 @@ type IssueDetailChatTabProps = {
|
||||
clientRequestId?: string,
|
||||
runSettings?: ComposerRunSettings,
|
||||
) => Promise<void>;
|
||||
onReviewConversation: () => Promise<void>;
|
||||
onImageUpload: (file: File) => Promise<string>;
|
||||
onAttachImage: (file: File) => Promise<IssueAttachment | void>;
|
||||
onInterruptQueued: (runId: string | null) => Promise<IssueQueuedCommentQueue | void>;
|
||||
@@ -1456,7 +1456,6 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
queuedCommentReason,
|
||||
onVote,
|
||||
onAdd,
|
||||
onReviewConversation,
|
||||
onImageUpload,
|
||||
onAttachImage,
|
||||
onInterruptQueued,
|
||||
@@ -2731,7 +2730,6 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
composerHint={composerHint}
|
||||
onVote={onVote}
|
||||
onAdd={onAdd}
|
||||
onReviewConversation={onReviewConversation}
|
||||
imageUploadHandler={onImageUpload}
|
||||
onAttachImage={onAttachImage}
|
||||
onInterruptQueued={interruptQueuedComments}
|
||||
@@ -4368,26 +4366,6 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
[issueId, queryClient],
|
||||
);
|
||||
|
||||
const applyOptimisticIssueCacheUpdate = useCallback(
|
||||
(refs: Iterable<string>, data: Record<string, unknown>) => {
|
||||
queryClient.setQueriesData<Issue>(
|
||||
{ queryKey: ["issues", "detail"] },
|
||||
(cached) =>
|
||||
cached && matchesIssueRef(cached, refs)
|
||||
? applyOptimisticIssueFieldUpdate(cached, data)
|
||||
: cached,
|
||||
);
|
||||
|
||||
if (!selectedCompanyId) return;
|
||||
queryClient.setQueryData<Issue[] | undefined>(
|
||||
queryKeys.issues.list(selectedCompanyId),
|
||||
(cached) =>
|
||||
applyOptimisticIssueFieldUpdateToCollection(cached, refs, data),
|
||||
);
|
||||
},
|
||||
[queryClient, selectedCompanyId],
|
||||
);
|
||||
|
||||
const mergeIssueResponseIntoCaches = useCallback(
|
||||
(refs: Iterable<string>, nextIssue: Issue) => {
|
||||
queryClient.setQueriesData<Issue>(
|
||||
@@ -4430,48 +4408,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
},
|
||||
});
|
||||
|
||||
const updateIssue = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
issuesApi.update(issueId!, data),
|
||||
onMutate: async (data) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: queryKeys.issues.detail(issueId!),
|
||||
});
|
||||
if (selectedCompanyId) {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId),
|
||||
});
|
||||
}
|
||||
|
||||
const previousIssue = queryClient.getQueryData<Issue>(
|
||||
queryKeys.issues.detail(issueId!),
|
||||
);
|
||||
const issueRefs = new Set<string>([issueId!]);
|
||||
if (previousIssue?.id) issueRefs.add(previousIssue.id);
|
||||
if (previousIssue?.identifier) issueRefs.add(previousIssue.identifier);
|
||||
|
||||
const previousDetailQueries = queryClient
|
||||
.getQueriesData<Issue>({ queryKey: ["issues", "detail"] })
|
||||
.filter(
|
||||
([, cachedIssue]) =>
|
||||
cachedIssue && matchesIssueRef(cachedIssue, issueRefs),
|
||||
);
|
||||
const previousList = selectedCompanyId
|
||||
? queryClient.getQueryData<Issue[]>(
|
||||
queryKeys.issues.list(selectedCompanyId),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
applyOptimisticIssueCacheUpdate(issueRefs, data);
|
||||
|
||||
return { previousDetailQueries, previousList, selectedCompanyId };
|
||||
},
|
||||
const updateIssueMutation = useUpdateIssueMutation({
|
||||
companyId: selectedCompanyId,
|
||||
onSuccess: ({
|
||||
comment: _comment,
|
||||
changes: _changes,
|
||||
blockedByIssueIds: _blockedByIssueIds,
|
||||
...nextIssue
|
||||
}, data) => {
|
||||
}, { data }) => {
|
||||
if (Object.prototype.hasOwnProperty.call(data, "projectId")) {
|
||||
trackRecentProject(nextIssue.projectId ?? "", nextIssue.companyId);
|
||||
}
|
||||
@@ -4486,35 +4430,16 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
});
|
||||
invalidateIssueCollections();
|
||||
},
|
||||
onError: (err, _variables, context) => {
|
||||
for (const [queryKey, previousIssue] of context?.previousDetailQueries ??
|
||||
[]) {
|
||||
queryClient.setQueryData(queryKey, previousIssue);
|
||||
}
|
||||
if (context?.selectedCompanyId) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.list(context.selectedCompanyId),
|
||||
context.previousList,
|
||||
);
|
||||
}
|
||||
pushToast({
|
||||
title: "Task update failed",
|
||||
body:
|
||||
err instanceof Error ? err.message : "Unable to save task changes",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.detail(issueId!),
|
||||
});
|
||||
if (selectedCompanyId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
// This page always updates its own issue.
|
||||
const updateIssue = useMemo(() => ({
|
||||
mutate: (data: Record<string, unknown>, options?: Parameters<typeof updateIssueMutation.mutate>[1]) =>
|
||||
updateIssueMutation.mutate({ id: issueId!, data }, options),
|
||||
mutateAsync: (data: Record<string, unknown>) =>
|
||||
updateIssueMutation.mutateAsync({ id: issueId!, data }),
|
||||
isPending: updateIssueMutation.isPending,
|
||||
variables: updateIssueMutation.variables?.data,
|
||||
}), [issueId, updateIssueMutation]);
|
||||
const resolveRecoveryAction = useMutation({
|
||||
mutationFn: (data: {
|
||||
actionId?: string;
|
||||
@@ -4961,12 +4886,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
},
|
||||
});
|
||||
|
||||
const addComment = useMutation({
|
||||
const addComment = useAccountBoundMutation({
|
||||
mutationFn: async ({ body, reopen, interrupt, attachmentIds, clientRequestId }: {
|
||||
body: string; reopen?: boolean; interrupt?: boolean; attachmentIds?: string[]; clientRequestId?: string;
|
||||
}) => {
|
||||
}, { assertCurrentAccount }) => {
|
||||
if (issue?.conversationAgentId) clearLegacyChatMessageRequests(`${issue.companyId}:${currentUserId}:${issue.conversationAgentId}`);
|
||||
return issuesApi.addComment(await resolveWritableIssueId(), body, reopen, interrupt, attachmentIds, clientRequestId ?? crypto.randomUUID());
|
||||
const writableIssueId = await resolveWritableIssueId();
|
||||
assertCurrentAccount();
|
||||
return issuesApi.addComment(writableIssueId, body, reopen, interrupt, attachmentIds, clientRequestId ?? crypto.randomUUID());
|
||||
},
|
||||
onMutate: async ({ body, reopen, interrupt }) => {
|
||||
// Start cache cancellation immediately but do not put it in front of the
|
||||
@@ -5109,14 +5036,15 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
context.previousIssue,
|
||||
);
|
||||
}
|
||||
pushToast({
|
||||
title:
|
||||
err instanceof CommentSubmissionUnknownError
|
||||
? "Comment save unconfirmed"
|
||||
: "Comment failed",
|
||||
body: err instanceof Error ? err.message : "Unable to post comment",
|
||||
tone: "error",
|
||||
});
|
||||
// A comment that is not known to be saved stays in the composer and
|
||||
// resends with the same request ID; its notice replaces this toast.
|
||||
if (classifySendFailure(err) === "pending") return;
|
||||
const { title, body } = describeError(err, { action: "post the comment" });
|
||||
pushToast({ title, body, tone: "error" });
|
||||
},
|
||||
onFinished: (_variables, context) => {
|
||||
if (context?.optimisticCommentId) setOptimisticComments(current =>
|
||||
current.filter(entry => entry.clientId !== context.optimisticCommentId));
|
||||
},
|
||||
onSettled: (result, _error, variables) => {
|
||||
if (result && !issueId) void queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(result.issueId) });
|
||||
@@ -5139,7 +5067,11 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
}
|
||||
},
|
||||
});
|
||||
const acceptInteraction = useMutation({
|
||||
// Resolutions may resend across an outage: the server refuses a second
|
||||
// resolution, and the API treats that refusal of our own earlier answer as
|
||||
// success.
|
||||
const acceptInteraction = useAccountBoundMutation({
|
||||
meta: { replay: "idempotent" },
|
||||
mutationFn: ({
|
||||
interaction,
|
||||
selectedClientKeys,
|
||||
@@ -5192,15 +5124,13 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
title: "Accept failed",
|
||||
body:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Unable to accept the suggested tasks",
|
||||
body: interactionResolutionErrorMessage(err),
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
const rejectInteraction = useMutation({
|
||||
const rejectInteraction = useAccountBoundMutation({
|
||||
meta: { replay: "idempotent" },
|
||||
mutationFn: ({
|
||||
interaction,
|
||||
reason,
|
||||
@@ -5223,15 +5153,13 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
title: "Reject failed",
|
||||
body:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Unable to reject the suggested tasks",
|
||||
body: interactionResolutionErrorMessage(err),
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
const answerInteraction = useMutation({
|
||||
const answerInteraction = useAccountBoundMutation({
|
||||
meta: { replay: "idempotent" },
|
||||
mutationFn: ({
|
||||
interaction,
|
||||
answers,
|
||||
@@ -5251,7 +5179,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
title: "Submit failed",
|
||||
body: err instanceof Error ? err.message : "Unable to submit answers",
|
||||
body: interactionResolutionErrorMessage(err),
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
@@ -5528,14 +5456,11 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
context.previousIssue,
|
||||
);
|
||||
}
|
||||
pushToast({
|
||||
title:
|
||||
err instanceof CommentSubmissionUnknownError
|
||||
? "Comment save unconfirmed"
|
||||
: "Comment failed",
|
||||
body: err instanceof Error ? err.message : "Unable to post comment",
|
||||
tone: "error",
|
||||
});
|
||||
// A comment that is not known to be saved stays in the composer and
|
||||
// resends with the same request ID; its notice replaces this toast.
|
||||
if (classifySendFailure(err) === "pending") return;
|
||||
const { title, body } = describeError(err, { action: "post the comment" });
|
||||
pushToast({ title, body, tone: "error" });
|
||||
},
|
||||
onSettled: (_result, _error, variables) => {
|
||||
if (_error) void queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state"] });
|
||||
@@ -7449,6 +7374,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
<InlineEditor
|
||||
value={issue.title}
|
||||
onSave={(title) => updateIssue.mutateAsync({ title })}
|
||||
draftKey={`issue:${issue.id}:title`}
|
||||
as="h2"
|
||||
className={cn(
|
||||
"min-w-0 text-balance",
|
||||
@@ -7870,6 +7796,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
<InlineEditor
|
||||
value={issue.title}
|
||||
onSave={(title) => updateIssue.mutateAsync({ title })}
|
||||
draftKey={`issue:${issue.id}:title`}
|
||||
as="h2"
|
||||
className={
|
||||
taskChatShellEnabled
|
||||
@@ -7897,6 +7824,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
<InlineEditor
|
||||
value={issue.description ?? ""}
|
||||
onSave={(description) => updateIssue.mutateAsync({ description })}
|
||||
draftKey={`issue:${issue.id}:description`}
|
||||
as="p"
|
||||
className="text-sm leading-7 text-foreground"
|
||||
placeholder="Add a description..."
|
||||
@@ -8511,15 +8439,6 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
queuedCommentReason={queuedCommentReason}
|
||||
onVote={handleCommentVote}
|
||||
onAdd={handleChatAdd}
|
||||
onReviewConversation={async () => {
|
||||
await Promise.all([
|
||||
refetchComments({ throwOnError: true }),
|
||||
queryClient.refetchQueries(
|
||||
{ queryKey: queryKeys.issues.attachments(issueId!) },
|
||||
{ throwOnError: true },
|
||||
),
|
||||
]);
|
||||
}}
|
||||
onImageUpload={handleCommentImageUpload}
|
||||
onAttachImage={handleCommentAttachImage}
|
||||
onInterruptQueued={handleInterruptQueuedRun}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DesignSandboxTasks } from "./DesignSandboxTasks";
|
||||
import { useEffect, useMemo, useCallback, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { useInfiniteQuery, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
taskView,
|
||||
type TaskViewKey,
|
||||
} from "../lib/task-views";
|
||||
import { useUpdateIssueMutation } from "../hooks/useUpdateIssueMutation";
|
||||
|
||||
const WORKSPACE_FILTER_ISSUE_LIMIT = 1000;
|
||||
const ISSUES_PAGE_SIZE = 100;
|
||||
@@ -172,7 +173,6 @@ function OrganizationIssues({
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const fetchNextPageInFlightRef = useRef(false);
|
||||
|
||||
const urlSearch = searchParams.get("q") ?? "";
|
||||
@@ -293,13 +293,8 @@ function OrganizationIssues({
|
||||
});
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const updateIssue = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
issuesApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) });
|
||||
},
|
||||
});
|
||||
// Optimistic, rolls back with a readable toast, and resends after an outage.
|
||||
const updateIssue = useUpdateIssueMutation({ companyId: selectedCompanyId });
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describeError } from "../api/errors";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -1624,7 +1625,7 @@ export function Inbox() {
|
||||
navigate(`/approvals/${id}?resolved=approved`);
|
||||
},
|
||||
onError: (err) => {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to approve");
|
||||
setActionError(describeError(err, { action: "approve this request" }).body);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1635,7 +1636,7 @@ export function Inbox() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||
},
|
||||
onError: (err) => {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to reject");
|
||||
setActionError(describeError(err, { action: "reject this request" }).body);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
useResourceMembershipMutation,
|
||||
useResourceMemberships,
|
||||
} from "../hooks/useResourceMemberships";
|
||||
import { useUpdateIssueMutation } from "../hooks/useUpdateIssueMutation";
|
||||
|
||||
/* ── Top-level tab types ── */
|
||||
|
||||
@@ -227,13 +228,9 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
|
||||
});
|
||||
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns, issues), [issues, liveRuns]);
|
||||
|
||||
const updateIssue = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
issuesApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
},
|
||||
const updateIssue = useUpdateIssueMutation({
|
||||
companyId,
|
||||
invalidateKeys: [queryKeys.issues.listByProject(companyId, projectId), queryKeys.issues.list(companyId)],
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -298,14 +295,13 @@ function ProjectPluginOperationsList({
|
||||
});
|
||||
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns, issues), [issues, liveRuns]);
|
||||
|
||||
const updateIssue = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
issuesApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listPluginOperationsByProject(companyId, projectId, originKindPrefix) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
},
|
||||
const updateIssue = useUpdateIssueMutation({
|
||||
companyId,
|
||||
invalidateKeys: [
|
||||
queryKeys.issues.listPluginOperationsByProject(companyId, projectId, originKindPrefix),
|
||||
queryKeys.issues.listByProject(companyId, projectId),
|
||||
queryKeys.issues.list(companyId),
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
selectedFolderFromList,
|
||||
type FolderSelection,
|
||||
} from "../components/folders/FolderControls";
|
||||
import { useUpdateIssueMutation } from "../hooks/useUpdateIssueMutation";
|
||||
|
||||
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
||||
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
|
||||
@@ -652,13 +653,9 @@ export function Routines() {
|
||||
() => createIssueDetailLocationState("Recent Runs", "/routines?tab=runs", "issues"),
|
||||
[],
|
||||
);
|
||||
const updateIssue = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => issuesApi.update(id, data),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"],
|
||||
});
|
||||
},
|
||||
const updateIssue = useUpdateIssueMutation({
|
||||
companyId: selectedCompanyId,
|
||||
invalidateKeys: selectedCompanyId ? [[...queryKeys.issues.list(selectedCompanyId), "routine-executions"]] : [],
|
||||
});
|
||||
|
||||
function handleLegacyTabChange(tab: string) {
|
||||
|
||||
@@ -345,11 +345,12 @@ export type PrivacyState = ReturnType<typeof createPrivacyState>;
|
||||
export function installPrivacyApi(state: PrivacyState) {
|
||||
const previous = window.fetch;
|
||||
const failed = new Set<string>();
|
||||
const oldDraft = localStorage.getItem("paperclip:issue-draft");
|
||||
localStorage.removeItem("paperclip:issue-draft");
|
||||
const draftKey = `paperclip:issue-draft:${state.user.id}`;
|
||||
const oldDraft = localStorage.getItem(draftKey);
|
||||
localStorage.removeItem(draftKey);
|
||||
if (state.options.draft)
|
||||
localStorage.setItem(
|
||||
"paperclip:issue-draft",
|
||||
draftKey,
|
||||
JSON.stringify({
|
||||
title: "Continue my private briefing",
|
||||
description: "Saved personal draft.",
|
||||
@@ -770,8 +771,8 @@ export function installPrivacyApi(state: PrivacyState) {
|
||||
};
|
||||
return () => {
|
||||
window.fetch = previous;
|
||||
if (oldDraft === null) localStorage.removeItem("paperclip:issue-draft");
|
||||
else localStorage.setItem("paperclip:issue-draft", oldDraft);
|
||||
if (oldDraft === null) localStorage.removeItem(draftKey);
|
||||
else localStorage.setItem(draftKey, oldDraft);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
|
||||
const COMPANY_ID = "company-storybook";
|
||||
const SELECTED_COMPANY_STORAGE_KEY = "paperclip.selectedCompanyId";
|
||||
const ISSUE_DRAFT_STORAGE_KEY = "paperclip:issue-draft";
|
||||
const ISSUE_DRAFT_STORAGE_KEY = `paperclip:issue-draft:${storybookAuthSession.user!.id}`;
|
||||
|
||||
const storybookGoals: Goal[] = [
|
||||
{
|
||||
|
||||
@@ -123,7 +123,7 @@ function NewTaskStory({
|
||||
}
|
||||
if (opened.current) return;
|
||||
opened.current = true;
|
||||
localStorage.removeItem("paperclip:issue-draft");
|
||||
localStorage.removeItem(`paperclip:issue-draft:${storybookAuthSession.user!.id}`);
|
||||
localStorage.removeItem("paperclip:recent-assignees");
|
||||
localStorage.removeItem(`paperclip:recent-assignees:${COMPANY_ID}`);
|
||||
localStorage.removeItem("paperclip:recent-projects");
|
||||
|
||||
@@ -169,7 +169,7 @@ function Sandbox({ options, children }: { options: PrimaryScenario; children: Re
|
||||
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { pushToast, dismissToast } = useToastActions();
|
||||
useLayoutEffect(() => {
|
||||
const storageKeys = ["paperclip:issue-draft", "paperclip:recent-assignees", `paperclip:recent-assignees:${companyId}`, `paperclip.recentAgentChats:${companyId}:user-board`, ...state.agents.map(a => `paperclip.recentAgentChatIssue:${companyId}:user-board:${a.id}`)];
|
||||
const storageKeys = [`paperclip:issue-draft:${storybookAuthSession.user!.id}`, "paperclip:recent-assignees", `paperclip:recent-assignees:${companyId}`, `paperclip.recentAgentChats:${companyId}:user-board`, ...state.agents.map(a => `paperclip.recentAgentChatIssue:${companyId}:user-board:${a.id}`)];
|
||||
const previous = storageKeys.map(key => [key, localStorage.getItem(key)] as const);
|
||||
storageKeys.forEach(key => localStorage.removeItem(key));
|
||||
const changed = () => {
|
||||
@@ -249,7 +249,7 @@ export function PrimaryPage({ task, drawer }: { task?: "primary" | "recent" | "e
|
||||
if (task && !launched.current) {
|
||||
launched.current = true;
|
||||
if (task === "draft") {
|
||||
localStorage.setItem("paperclip:issue-draft", JSON.stringify({ title: "Review the launch mockups", description: "Keep the mobile navigation compact.", companyId, status: "todo", priority: "medium", assigneeValue: `agent:${designer.id}`, projectId: "", reviewerValue: "", approverValue: "", assigneeModelOverride: "", assigneeThinkingEffort: "", assigneeChrome: false }));
|
||||
localStorage.setItem(`paperclip:issue-draft:${storybookAuthSession.user!.id}`, JSON.stringify({ title: "Review the launch mockups", description: "Keep the mobile navigation compact.", companyId, status: "todo", priority: "medium", assigneeValue: `agent:${designer.id}`, projectId: "", reviewerValue: "", approverValue: "", assigneeModelOverride: "", assigneeThinkingEffort: "", assigneeChrome: false }));
|
||||
openNewIssue();
|
||||
} else {
|
||||
openNewIssue({ title: "Plan next week's launch", ...(task === "explicit" ? { assigneeAgentId: designer.id } : task === "primary" ? { assigneeAgentId: state.primaryId ?? undefined } : {}) });
|
||||
|
||||
Reference in new issue
Block a user