@remit/web-client 0.0.270 → 0.0.272

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.270",
3
+ "version": "0.0.272",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -0,0 +1,331 @@
1
+ import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import { useQueryClient } from "@tanstack/react-query";
3
+ import {
4
+ createContext,
5
+ type ReactNode,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ } from "react";
13
+ import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
14
+ import { buildMutationErrorBanner } from "@/components/ui/error-banners";
15
+ import { isFolderRoleRefusal } from "@/components/ui/folder-role-refusal";
16
+ import {
17
+ bulkActionFailureDetail,
18
+ bulkActionFailureTitle,
19
+ bulkRunBusyRefusal,
20
+ } from "@/lib/bulk-action-copy";
21
+ import {
22
+ type ApplyBatch,
23
+ type BulkActionProgress,
24
+ type BulkActionTarget,
25
+ type BulkRunOutcome,
26
+ type BulkRunStart,
27
+ type EscalatedAction,
28
+ type FetchIdsPage,
29
+ honestProgress,
30
+ mailboxesTouchedBy,
31
+ runChunkedAction,
32
+ runPredicateAction,
33
+ } from "@/lib/bulk-actions";
34
+ import {
35
+ invalidateThreadListQueries,
36
+ threadListCacheKeys,
37
+ } from "@/lib/thread-list-cache";
38
+ import { useRoleAppointmentPrompt } from "./RoleAppointmentPromptProvider";
39
+
40
+ /**
41
+ * What the run pages, which is the one thing a bounded and an escalated run do
42
+ * differently: a ticked selection is a list of ids the surface already holds,
43
+ * an escalated one is a predicate the server re-resolves page by page.
44
+ */
45
+ export type BulkRunSource =
46
+ | { kind: "targets"; targets: readonly BulkActionTarget[] }
47
+ | { kind: "predicate"; fetchPage: FetchIdsPage };
48
+
49
+ export interface BulkRunRequest {
50
+ action: EscalatedAction;
51
+ /** The mailbox the run belongs to — where its progress shows on return. */
52
+ mailboxId: string;
53
+ /** That mailbox in the user's words, for a refusal that names this run. */
54
+ mailboxLabel?: string;
55
+ /** Owning account, for the unseen-count invalidation on completion. */
56
+ accountId: string | undefined;
57
+ /** The count the run was offered against, for the bar and for the ending. */
58
+ matched: number;
59
+ source: BulkRunSource;
60
+ /** Issues one batch of the action. Closed over the SDK, never over a screen. */
61
+ applyBatch: ApplyBatch;
62
+ /**
63
+ * States how the run ended, once no screen is left reporting on it in place.
64
+ * Called by the provider rather than by the caller, so an ending that lands
65
+ * after the surface unmounted is still said out loud (#112). Absent on a
66
+ * surface with nowhere to say it.
67
+ */
68
+ reportEnding?: (
69
+ kind: EscalatedAction["kind"],
70
+ matched: number,
71
+ outcome: BulkRunOutcome,
72
+ ) => void;
73
+ /**
74
+ * Handed the release for this run's ending, for a screen that is showing the
75
+ * ending itself. The claim is made inside `start`, for the run it starts, so
76
+ * a commit that starts no run holds nothing over the ending of one that is
77
+ * already going (#112). Released when that screen goes.
78
+ */
79
+ claimEnding?: (release: () => void) => void;
80
+ }
81
+
82
+ /** A run in flight, as any screen reads it. */
83
+ export interface ActiveBulkRun {
84
+ action: EscalatedAction;
85
+ mailboxId: string;
86
+ /** Seeded from the count the run was offered against, so the bar has a
87
+ * denominator before the first batch comes back. */
88
+ progress: BulkActionProgress;
89
+ }
90
+
91
+ interface BulkRunContextValue {
92
+ run: ActiveBulkRun | undefined;
93
+ /**
94
+ * Runs `request`, or refuses it because one is already going. There is one
95
+ * slot: a second run would page behind the first's bar and answer to the
96
+ * first's Stop, so it is refused with the run in flight named rather than
97
+ * queued or started alongside.
98
+ */
99
+ start: (request: BulkRunRequest) => Promise<BulkRunStart>;
100
+ /**
101
+ * Ends the run `mailboxId` owns: the request on the wire is aborted and
102
+ * nothing more leaves. A stop aimed at any other mailbox is not this run's,
103
+ * and reaches nothing.
104
+ */
105
+ stop: (mailboxId: string) => void;
106
+ }
107
+
108
+ const BulkRunContext = createContext<BulkRunContextValue | undefined>(
109
+ undefined,
110
+ );
111
+
112
+ /**
113
+ * The owner of a chunked bulk run (#112).
114
+ *
115
+ * A run is mail leaving the mailbox, and it outlives every screen that can show
116
+ * it: the wizard invites the user to close it mid-run, and nothing stops them
117
+ * navigating to another mailbox on top of that. Held in the surface that started
118
+ * it, the progress, the count and the cancellation all unmounted with the route,
119
+ * so returning showed nothing, Stop reached nothing, and an ending that landed
120
+ * after the move was dropped in silence.
121
+ *
122
+ * Mounted at the root beside the other app-lifetime providers, this holds the
123
+ * run instead: its progress, its `AbortController`, the caches it invalidates,
124
+ * the refusal it replays and the ending it states. A screen reads the run and
125
+ * offers Stop; it never owns either.
126
+ *
127
+ * In memory and per tab. A run is a sequence of requests this tab is making, so
128
+ * a reload ends it — persisting the reading would show a bar for a run nothing
129
+ * is driving.
130
+ */
131
+ export const BulkRunProvider = ({ children }: { children: ReactNode }) => {
132
+ const [run, setRun] = useState<ActiveBulkRun | undefined>(undefined);
133
+ // The signal every call of the live run is issued under, so a stop ends the
134
+ // request on the wire instead of waiting for it to come back (#113).
135
+ const controllerRef = useRef<AbortController | undefined>(undefined);
136
+ // Identifies the live run across the awaits below, so a run that ended after
137
+ // the slot was taken from it — by a sign-out — writes nothing back.
138
+ const liveRunRef = useRef<symbol | undefined>(undefined);
139
+ // What is in the slot, read synchronously: two commits landing in one frame
140
+ // both have to see the first of them.
141
+ const liveRequestRef = useRef<BulkRunRequest | undefined>(undefined);
142
+ // The runs a screen is reporting on in place, by token — never a count of
143
+ // claims over runs in general, which any commit could hold against any run.
144
+ const claimsRef = useRef(new Set<symbol>());
145
+ const queryClient = useQueryClient();
146
+ const { pushError } = useErrorBanners();
147
+ const { requestAppointment } = useRoleAppointmentPrompt();
148
+ // The run replays itself once a folder is appointed, so it needs a handle on
149
+ // itself that does not make `start` its own dependency.
150
+ const startRef = useRef<BulkRunContextValue["start"]>(async () => ({
151
+ kind: "ran",
152
+ outcome: { done: 0, failedIds: [], cancelled: false },
153
+ }));
154
+
155
+ /**
156
+ * The listings a run changed, and the unseen counts it moved. A cross-account
157
+ * selection has no single owning account, so the run's own targets name the
158
+ * accounts to refresh alongside the one the surface named.
159
+ */
160
+ const invalidateAfterRun = useCallback(
161
+ (request: BulkRunRequest) => {
162
+ invalidateThreadListQueries(
163
+ queryClient,
164
+ threadListCacheKeys(
165
+ mailboxesTouchedBy(request.action, request.mailboxId),
166
+ ),
167
+ );
168
+ const touched = new Set<string>();
169
+ if (request.accountId) touched.add(request.accountId);
170
+ if (request.source.kind === "targets") {
171
+ for (const target of request.source.targets) {
172
+ if (target.accountId) touched.add(target.accountId);
173
+ }
174
+ }
175
+ for (const accountId of touched) {
176
+ queryClient.invalidateQueries({
177
+ queryKey: mailboxOperationsListMailboxesQueryKey({
178
+ path: { accountId },
179
+ }),
180
+ });
181
+ }
182
+ },
183
+ [queryClient],
184
+ );
185
+
186
+ const start = useCallback(
187
+ async (request: BulkRunRequest): Promise<BulkRunStart> => {
188
+ const inFlight = liveRequestRef.current;
189
+ if (inFlight) {
190
+ return {
191
+ kind: "refused",
192
+ reason: bulkRunBusyRefusal(
193
+ inFlight.action.kind,
194
+ inFlight.matched,
195
+ inFlight.mailboxLabel,
196
+ ),
197
+ };
198
+ }
199
+ const token = Symbol("bulk-run");
200
+ const controller = new AbortController();
201
+ controllerRef.current = controller;
202
+ liveRunRef.current = token;
203
+ liveRequestRef.current = request;
204
+ if (request.claimEnding) {
205
+ claimsRef.current.add(token);
206
+ request.claimEnding(() => claimsRef.current.delete(token));
207
+ }
208
+ setRun({
209
+ action: request.action,
210
+ mailboxId: request.mailboxId,
211
+ progress: { done: 0, total: request.matched },
212
+ });
213
+ // `honestProgress` widens `total` if `done` overtakes it (#109) — the
214
+ // predicate can match more by the time the run pages it than the count
215
+ // saw, and the bar must never show more done than out of.
216
+ const onProgress = (next: BulkActionProgress) => {
217
+ if (liveRunRef.current !== token) return;
218
+ setRun((held) =>
219
+ held ? { ...held, progress: honestProgress(next) } : held,
220
+ );
221
+ };
222
+
223
+ const outcome =
224
+ request.source.kind === "targets"
225
+ ? await runChunkedAction(
226
+ request.source.targets,
227
+ request.applyBatch,
228
+ onProgress,
229
+ controller.signal,
230
+ )
231
+ : await runPredicateAction(
232
+ request.source.fetchPage,
233
+ request.matched,
234
+ request.applyBatch,
235
+ onProgress,
236
+ controller.signal,
237
+ );
238
+
239
+ const reportedInPlace = claimsRef.current.delete(token);
240
+ if (liveRunRef.current === token) {
241
+ liveRunRef.current = undefined;
242
+ liveRequestRef.current = undefined;
243
+ setRun(undefined);
244
+ }
245
+
246
+ // A provenance refusal is answered by the appointment prompt exactly as
247
+ // it is on the single-row path (#887, #876). Left to the banner it
248
+ // arrives as the API's own sentence — "Appoint one under Settings ›
249
+ // Folder roles" — which asks the user to reassemble a select-all they
250
+ // have already made.
251
+ const refusal = outcome.error
252
+ ? isFolderRoleRefusal(outcome.error)
253
+ : undefined;
254
+ if (refusal) {
255
+ requestAppointment({
256
+ accountId: refusal.accountId,
257
+ role: refusal.role,
258
+ reason: refusal.reason,
259
+ action: { kind: "delete", count: request.matched },
260
+ // The replay is a new run, and it claims nothing: the claim the
261
+ // original was started under belongs to a screen whose release has
262
+ // already fired or never will for this run, so re-claiming would
263
+ // leave the replay's ending held back by nobody and never said.
264
+ onAppointed: async () => {
265
+ const replayed = await startRef.current({
266
+ ...request,
267
+ claimEnding: undefined,
268
+ });
269
+ if (replayed.kind === "refused") {
270
+ pushError({
271
+ title: bulkActionFailureTitle(request.action.kind, 0),
272
+ detail: replayed.reason,
273
+ });
274
+ }
275
+ },
276
+ });
277
+ } else if (outcome.error) {
278
+ pushError(
279
+ buildMutationErrorBanner(
280
+ bulkActionFailureTitle(request.action.kind, outcome.done),
281
+ bulkActionFailureDetail(request.action.kind),
282
+ outcome.error,
283
+ ),
284
+ );
285
+ }
286
+ // Nobody is left showing this run, so the ending is said where the user
287
+ // now is. A screen still reporting on it says it in place instead.
288
+ if (!reportedInPlace) {
289
+ request.reportEnding?.(request.action.kind, request.matched, outcome);
290
+ }
291
+ if (outcome.done > 0) invalidateAfterRun(request);
292
+ return { kind: "ran", outcome };
293
+ },
294
+ [invalidateAfterRun, pushError, requestAppointment],
295
+ );
296
+ startRef.current = start;
297
+
298
+ const stop = useCallback((mailboxId: string) => {
299
+ if (liveRequestRef.current?.mailboxId !== mailboxId) return;
300
+ controllerRef.current?.abort();
301
+ }, []);
302
+
303
+ // A run belongs to the session that started it. Both auth shells gate the
304
+ // whole app on the session, so signing out or switching accounts takes this
305
+ // provider down with the router — and a run left paging past that would go on
306
+ // deleting mail for an account nobody is signed in to.
307
+ useEffect(() => {
308
+ const claims = claimsRef.current;
309
+ return () => {
310
+ controllerRef.current?.abort();
311
+ controllerRef.current = undefined;
312
+ liveRunRef.current = undefined;
313
+ liveRequestRef.current = undefined;
314
+ claims.clear();
315
+ };
316
+ }, []);
317
+
318
+ const value = useMemo(() => ({ run, start, stop }), [run, start, stop]);
319
+
320
+ return (
321
+ <BulkRunContext.Provider value={value}>{children}</BulkRunContext.Provider>
322
+ );
323
+ };
324
+
325
+ export const useBulkRun = (): BulkRunContextValue => {
326
+ const context = useContext(BulkRunContext);
327
+ if (!context) {
328
+ throw new Error("useBulkRun must be used within a BulkRunProvider");
329
+ }
330
+ return context;
331
+ };
@@ -24,7 +24,6 @@ import { formatErrorMessage } from "@/components/ui/ErrorState";
24
24
  import { useJunkMailbox } from "@/hooks/useArchiveMailbox";
25
25
  import { useDeleteOutcome } from "@/hooks/useDeleteOutcome";
26
26
  import {
27
- type EscalatedAction,
28
27
  type EscalationSearchQuery,
29
28
  useEscalatedActions,
30
29
  } from "@/hooks/useEscalatedActions";
@@ -355,21 +354,12 @@ export const MessageList = ({
355
354
  // are loaded. `orderedIds` below feeds `allLoadedSelected`; declared after
356
355
  // this hook so its callback deps stay simple — see the
357
356
  // `orderedIds`/`handleRowSelect` block.
358
- const escalationEnabled = isSearching && !!searchPredicate;
359
- const predicateKey = `${mailboxId}|${JSON.stringify(searchPredicate ?? {})}`;
360
- const escalation = useEscalatedActions({
361
- mailboxId,
362
- accountId,
363
- enabled: escalationEnabled,
364
- predicateKey,
365
- searchQuery: searchPredicate ?? {},
366
- });
367
-
368
- // How a run ended, for the user who is no longer looking at the run screen.
369
- // Closing the wizard mid-run is a movement the screen invites, and the run
370
- // keeps going past it — so the list is what states the ending, whether the
371
- // run covered everything or stopped short of it. The wizard calls this only
372
- // once the user has left it, so an ending is never said twice.
357
+ // How a run ended, for the user who is no longer looking at any screen that
358
+ // could show it. Closing the wizard mid-run is a movement the screen invites,
359
+ // and leaving the mailbox on top of that is one nothing prevents — the run
360
+ // keeps going past both, so this states the ending, whether the run covered
361
+ // everything or stopped short of it. The run's owner calls it only once no
362
+ // screen is left reporting in place, so an ending is never said twice.
373
363
  const reportRunOutcome = useCallback(
374
364
  (kind: BulkActionKind, matched: number, outcome: BulkRunOutcome) => {
375
365
  const banner = runEndingBanner(kind, matched, outcome, deleteOutcome);
@@ -380,6 +370,18 @@ export const MessageList = ({
380
370
  [pushError, deleteOutcome],
381
371
  );
382
372
 
373
+ const escalationEnabled = isSearching && !!searchPredicate;
374
+ const predicateKey = `${mailboxId}|${JSON.stringify(searchPredicate ?? {})}`;
375
+ const escalation = useEscalatedActions({
376
+ mailboxId,
377
+ mailboxLabel: listTitle,
378
+ accountId,
379
+ enabled: escalationEnabled,
380
+ predicateKey,
381
+ searchQuery: searchPredicate ?? {},
382
+ reportEnding: reportRunOutcome,
383
+ });
384
+
383
385
  // The one way selection mode ends (#115): cancel, a completed delete or
384
386
  // move, a plain click that collapses a range, switching mailboxes, the back
385
387
  // gesture. Nothing calls the selection hook's `clearSelection` directly, so
@@ -690,7 +692,8 @@ export const MessageList = ({
690
692
  scope: describeSearchScope(searchPredicate ?? {}),
691
693
  total: escalation.phase.total,
692
694
  searchQuery: searchPredicate ?? {},
693
- run: (action: EscalatedAction) => escalation.runAction(action),
695
+ run: (action, claimEnding) =>
696
+ escalation.runAction(action, undefined, claimEnding),
694
697
  stop: escalation.stop,
695
698
  }
696
699
  : undefined;
@@ -1086,10 +1089,16 @@ export const MessageList = ({
1086
1089
  // The escalation-derived surface state — viewport-independent, fed to both
1087
1090
  // the mobile sheet and the desktop toolbar so the two never diverge (#212).
1088
1091
  const selectionIsBusy = isDeleting || isMoving || escalation.isRunning;
1092
+ // A run outlives the selection it came from, and leaving the mailbox and
1093
+ // coming back leaves no selection at all — so while one is in flight the run's
1094
+ // own count is what the bar is about, or the bar would be an empty header over
1095
+ // mail that is still being deleted (#112).
1089
1096
  const selectionCount =
1090
1097
  escalation.phase.kind === "escalated"
1091
1098
  ? escalation.phase.total
1092
- : selectedCount;
1099
+ : escalation.progress
1100
+ ? escalation.progress.total
1101
+ : selectedCount;
1093
1102
 
1094
1103
  // A run reports on the bar from the moment it starts, not from its first
1095
1104
  // finished batch: the wizard invites the user back here mid-run, and a run
@@ -1128,17 +1137,22 @@ export const MessageList = ({
1128
1137
  : undefined;
1129
1138
 
1130
1139
  // At most one escalation notice at a time, ranked by how actionable it is:
1131
- // an in-progress counting/escalated state and its own action always wins;
1132
- // otherwise a fresh escalation offer. The (rare) cross-account move hint is
1133
- // layered on behind them below.
1140
+ // a run in flight and a count both carry Stop, and that always wins; then an
1141
+ // escalated selection's way out; otherwise a fresh escalation offer. The
1142
+ // (rare) cross-account move hint is layered on behind them below.
1143
+ //
1144
+ // Stop rides on the bar rather than only on the wizard's run screen because
1145
+ // the run outlives both the wizard and the route: a user who comes back to
1146
+ // the mailbox mid-run finds the progress there, and has to be able to end it
1147
+ // from the same place (#112).
1134
1148
  const escalationNotice =
1135
- escalation.phase.kind === "counting"
1149
+ escalation.isRunning || escalation.phase.kind === "counting"
1136
1150
  ? {
1137
1151
  tone: "info" as const,
1138
1152
  text: "",
1139
1153
  action: { label: "Stop", onClick: escalation.stop },
1140
1154
  }
1141
- : escalation.phase.kind === "escalated" && !escalation.isRunning
1155
+ : escalation.phase.kind === "escalated"
1142
1156
  ? {
1143
1157
  tone: "info" as const,
1144
1158
  text: "",
@@ -1343,6 +1357,7 @@ export const MessageList = ({
1343
1357
  verb={wizardVerb}
1344
1358
  accountId={accountId}
1345
1359
  mailboxId={mailboxId}
1360
+ mailboxLabel={listTitle}
1346
1361
  selection={wizardSelection}
1347
1362
  selectionRestriction={moveDisabledHint ? "spansAccounts" : undefined}
1348
1363
  escalated={escalatedSelection}
@@ -39,7 +39,6 @@ import {
39
39
  import { useClauseSuggestions } from "@/hooks/useClauseSuggestions";
40
40
  import { useCreateMailbox } from "@/hooks/useCreateMailbox";
41
41
  import {
42
- type EscalatedAction,
43
42
  type EscalationSearchQuery,
44
43
  useEscalatedActions,
45
44
  } from "@/hooks/useEscalatedActions";
@@ -55,6 +54,8 @@ import type {
55
54
  BulkActionProgress,
56
55
  BulkActionTarget,
57
56
  BulkRunOutcome,
57
+ BulkRunStart,
58
+ EscalatedAction,
58
59
  } from "@/lib/bulk-actions";
59
60
  import { NO_JUNK_FOLDER_REASON } from "@/lib/junk-destination";
60
61
  import { useMailContext } from "@/lib/mail-context";
@@ -122,7 +123,15 @@ export interface EscalatedSelection {
122
123
  * screen is what now stands in front of it. The run belongs to the list, so
123
124
  * it survives the wizard closing over it — as the run screen promises.
124
125
  */
125
- run: (action: EscalatedAction) => Promise<BulkRunOutcome>;
126
+ run: (
127
+ action: EscalatedAction,
128
+ /**
129
+ * Handed the release for the ending of the run it starts, so the wizard's
130
+ * run screen states that ending in place and the list does not banner it a
131
+ * second time (#112).
132
+ */
133
+ claimEnding: (release: () => void) => void,
134
+ ) => Promise<BulkRunStart>;
126
135
  /** Ends that run at the next page boundary, which leaving the wizard does not. */
127
136
  stop: () => void;
128
137
  }
@@ -134,6 +143,8 @@ export interface SelectionWizardHostProps {
134
143
  accountId?: string;
135
144
  /** Where the selection was made, so a run invalidates the listing it changed. */
136
145
  mailboxId?: string;
146
+ /** That mailbox in the user's words, for a commit refused while it runs. */
147
+ mailboxLabel?: string;
137
148
  selection: readonly WizardSelectionMessage[];
138
149
  /**
139
150
  * Which scope the ticked rows span more of than the folder and rule steps can
@@ -154,10 +165,11 @@ export interface SelectionWizardHostProps {
154
165
  /** The wizard is done with the selection, and the list drops it. */
155
166
  onFinished: () => void;
156
167
  /**
157
- * How a run ended, once the screen that was reporting on it is gone. The run
158
- * screen invites the user to close it and keep the run going, so the surface
159
- * that outlives the wizard is what states the ending. Called only for a run
160
- * the user walked away from; a run screen still up reports in place.
168
+ * How a run ended, once no screen reporting on it is left. Handed to the run's
169
+ * owner rather than called here: the run screen invites the user to close it
170
+ * and keep the run going, and leaving the mailbox on top of that takes this
171
+ * component with it — so the ending is stated by something that outlives both
172
+ * (#112). A run screen still up reports in place instead.
161
173
  */
162
174
  onRunEnded?: (
163
175
  kind: EscalatedAction["kind"],
@@ -298,6 +310,7 @@ function SelectionWizardSession({
298
310
  verb,
299
311
  accountId,
300
312
  mailboxId,
313
+ mailboxLabel,
301
314
  selection,
302
315
  selectionRestriction,
303
316
  escalated: escalatedSelection,
@@ -361,11 +374,6 @@ function SelectionWizardSession({
361
374
  // cleared, so a failed start can be retried.
362
375
  const [backApplyDraft, setBackApplyDraft] = useState<OrganizeDraft>();
363
376
  const commitSent = useRef(false);
364
- // The user left the run screen while it was still reporting. Held here rather
365
- // than read back off the URL: the rewind the exit starts lands a frame or two
366
- // later, and an outcome arriving in between would find a screen that is on
367
- // its way out and say nothing.
368
- const walkedAway = useRef(false);
369
377
 
370
378
  const messageIds = useMemo(
371
379
  () => selection.map((message) => message.id),
@@ -491,14 +499,36 @@ function SelectionWizardSession({
491
499
  senders,
492
500
  );
493
501
 
502
+ // While this screen is up it states the ending itself, so the run's owner
503
+ // holds its banner back. The claim is over the run this screen starts and no
504
+ // other: a commit that starts none — a filter, a back-apply job — has no
505
+ // ending of its own to hold, and holding one anyway swallowed the banner of
506
+ // whatever run was going elsewhere. Leaving — closing the wizard, or leaving
507
+ // the mailbox altogether — unmounts this and releases it, and the ending is
508
+ // bannered where the user now is (#112, #521).
509
+ const releaseReport = useRef<(() => void) | undefined>(undefined);
510
+ const claimEnding = useCallback((release: () => void) => {
511
+ releaseReport.current?.();
512
+ releaseReport.current = release;
513
+ }, []);
514
+ useEffect(
515
+ () => () => {
516
+ releaseReport.current?.();
517
+ releaseReport.current = undefined;
518
+ },
519
+ [],
520
+ );
521
+
494
522
  const organizeJob = useOrganizeJob(accountId);
495
523
  const createFilter = useCreateFilter(accountId);
496
524
  const bulk = useEscalatedActions({
497
525
  mailboxId: mailboxId ?? "",
526
+ mailboxLabel,
498
527
  accountId,
499
528
  enabled: false,
500
529
  predicateKey: "selection-wizard",
501
530
  searchQuery: {},
531
+ reportEnding: onRunEnded,
502
532
  });
503
533
  const { runAction } = bulk;
504
534
 
@@ -661,13 +691,15 @@ function SelectionWizardSession({
661
691
  return;
662
692
  }
663
693
  setBulkRun({ matched: targets.length, sent: targets });
664
- const outcome = await runAction(action, targets);
665
- setBulkRun({ matched: targets.length, sent: targets, outcome });
666
- if (walkedAway.current) {
667
- onRunEnded?.(action.kind, targets.length, outcome);
668
- }
694
+ const started = await runAction(action, targets, claimEnding);
695
+ setBulkRun({
696
+ matched: targets.length,
697
+ sent: targets,
698
+ outcome: started.kind === "ran" ? started.outcome : undefined,
699
+ failureReason: started.kind === "refused" ? started.reason : undefined,
700
+ });
669
701
  },
670
- [verb, named.moveMailboxId, junkMailboxId, runAction, onRunEnded],
702
+ [verb, named.moveMailboxId, junkMailboxId, runAction, claimEnding],
671
703
  );
672
704
 
673
705
  // The escalated predicate, run by the chunked runner the list already owns.
@@ -685,12 +717,14 @@ function SelectionWizardSession({
685
717
  return;
686
718
  }
687
719
  setBulkRun({ matched: escalated.total, sent: [] });
688
- const outcome = await escalated.run(action);
689
- setBulkRun({ matched: escalated.total, sent: [], outcome });
690
- if (walkedAway.current) {
691
- onRunEnded?.(action.kind, escalated.total, outcome);
692
- }
693
- }, [escalated, verb, named.moveMailboxId, junkMailboxId, onRunEnded]);
720
+ const started = await escalated.run(action, claimEnding);
721
+ setBulkRun({
722
+ matched: escalated.total,
723
+ sent: [],
724
+ outcome: started.kind === "ran" ? started.outcome : undefined,
725
+ failureReason: started.kind === "refused" ? started.reason : undefined,
726
+ });
727
+ }, [escalated, verb, named.moveMailboxId, junkMailboxId, claimEnding]);
694
728
 
695
729
  const { start: startJob } = organizeJob;
696
730
  const { createFilterAsync } = createFilter;
@@ -909,7 +943,6 @@ function SelectionWizardSession({
909
943
  // run alone: the screen says so in as many words, and stopping it is a
910
944
  // control of its own.
911
945
  const dismiss = useCallback(() => {
912
- walkedAway.current = true;
913
946
  closeWizard(steps, current);
914
947
  onFinished();
915
948
  }, [closeWizard, steps, current, onFinished]);
@@ -20,11 +20,11 @@
20
20
  import assert from "node:assert/strict";
21
21
  import { afterEach, beforeEach, describe, it } from "node:test";
22
22
  import { act, createElement } from "react";
23
- import type { BulkActionTarget } from "../lib/bulk-actions";
23
+ import type { BulkActionTarget, EscalatedAction } from "../lib/bulk-actions";
24
+ import { ranOutcome } from "../test-support/bulk-run";
24
25
  import { createDomHarness, type DomHarness } from "../test-support/dom";
25
26
  import { type HttpMock, httpError, mockFetch } from "../test-support/http";
26
27
  import {
27
- type EscalatedAction,
28
28
  type UseEscalatedActionsResult,
29
29
  useEscalatedActions,
30
30
  } from "./useEscalatedActions";
@@ -80,7 +80,7 @@ const run = async (
80
80
  });
81
81
  await settle();
82
82
  if (!started) throw new Error("the run never started");
83
- return started;
83
+ return ranOutcome(await started);
84
84
  };
85
85
 
86
86
  /** The message-id list of every bulk request that left, in order. */