@remit/web-client 0.0.187 → 0.0.189

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.
@@ -0,0 +1,343 @@
1
+ /**
2
+ * The mailbox's intelligence keys act on the surface the width has (#840).
3
+ *
4
+ * `i` and `b` are registered by the pane provider, which sits above the shell
5
+ * and so cannot see the rail's own width gate. Both reached for the rail's
6
+ * toggle directly, and below 1280 that wrote `#intelligence` into the address
7
+ * with no rail mounted to render it — a panel the address names and nothing
8
+ * answers (`docs/architecture/url-state.md`, R6). The drawer is the surface at
9
+ * those widths, and it is what the toolbar's own control already used.
10
+ *
11
+ * Mounted in the real `AppShellSlotted` at all three tiers, with the `/mail`
12
+ * layout's binding between the fragment and the rail: which panes a width has
13
+ * is the shell's own answer here, and what the address holds is the router's.
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { afterEach, describe, it } from "node:test";
18
+ import { AppShellSlotted } from "@remit/ui";
19
+ import {
20
+ type AnyRouter,
21
+ createMemoryHistory,
22
+ createRootRoute,
23
+ createRoute,
24
+ createRouter,
25
+ Outlet,
26
+ RouterProvider,
27
+ } from "@tanstack/react-router";
28
+ import { createElement, type ReactNode, useCallback } from "react";
29
+ import { ComposeProvider } from "@/components/compose/ComposeProvider";
30
+ import { MailContext, type MailContextValue } from "@/lib/mail-context";
31
+ import { EMPTY_RESULT_FOLDER_INDEX } from "@/lib/result-folder";
32
+ import { useOpenPanels, useOpenThreadPath, useSetOpenPanels } from "@/routing";
33
+ import {
34
+ createDomHarness,
35
+ type DomHarness,
36
+ type DomOptions,
37
+ } from "@/test-support/dom";
38
+ import { makeThreadMessage } from "@/test-support/fixtures";
39
+ import { type HttpMock, mockFetch } from "@/test-support/http";
40
+ import {
41
+ describedMessage,
42
+ intelligenceDrawer,
43
+ MESSAGE_ID,
44
+ settle,
45
+ THREAD_ID,
46
+ } from "@/test-support/intelligence-surface";
47
+ import { MailboxPane } from "./MailboxPane";
48
+
49
+ /** Below the rail's 1280px gate, above the reading pane's 1024px one. */
50
+ const TWO_PANE_WIDTH = 1100;
51
+ /** Wide enough for the rail, which is where the fragment raises it. */
52
+ const RAIL_WIDTH = 1400;
53
+ /** One pane, where the list route mounts its phone view instead of the slots. */
54
+ const PHONE_WIDTH = 420;
55
+
56
+ const MAILBOX_ID = "mailbox-1";
57
+ const MESSAGE_PATH = `/mail/${MAILBOX_ID}/${THREAD_ID}/${MESSAGE_ID}`;
58
+
59
+ /**
60
+ * No DKIM mismatch: the auto-open raises the rail on its own where the rail
61
+ * fits, which would put `#intelligence` in the address before any key is
62
+ * pressed.
63
+ */
64
+ const row = makeThreadMessage({
65
+ messageId: MESSAGE_ID,
66
+ threadId: THREAD_ID,
67
+ subject: "Your parcel could not be delivered",
68
+ fromName: "Mondial Relay",
69
+ fromEmail: "delivery.notice@gmail.example",
70
+ });
71
+
72
+ let harness: DomHarness | undefined;
73
+ let http: HttpMock | undefined;
74
+
75
+ afterEach(() => {
76
+ harness?.close();
77
+ harness = undefined;
78
+ http?.restore();
79
+ http = undefined;
80
+ });
81
+
82
+ // The router reads `self` at construction; the shared jsdom globals stop at
83
+ // `window`.
84
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
85
+
86
+ /**
87
+ * What `routes/mail.tsx` binds: the rail's open state is the fragment, and the
88
+ * toggle rewrites the fragment. The device preference is left out — nothing
89
+ * here is about the tier the address is silent on.
90
+ */
91
+ function MailLayout({ children }: { children: ReactNode }) {
92
+ const openPanels = useOpenPanels();
93
+ const setOpenPanels = useSetOpenPanels();
94
+ const intelligenceOpen = openPanels.includes("intelligence");
95
+ const onToggleIntelligence = useCallback(() => {
96
+ setOpenPanels(intelligenceOpen ? [] : ["intelligence"]);
97
+ }, [intelligenceOpen, setOpenPanels]);
98
+
99
+ const value: MailContextValue = {
100
+ accounts: [],
101
+ mailboxNameIndex: new Map(),
102
+ accountNameIndex: new Map(),
103
+ resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
104
+ searchQuery: "",
105
+ searchInput: "",
106
+ searchViewKey: "",
107
+ onSearchChange: () => {},
108
+ onSearchClear: () => {},
109
+ onSearchClearQuery: () => {},
110
+ intelligenceOpen,
111
+ onToggleIntelligence,
112
+ };
113
+ return createElement(MailContext.Provider, { value }, children);
114
+ }
115
+
116
+ /**
117
+ * The shell as `MailShell` mounts it: one pane carrying the phone view below
118
+ * the reading boundary, the slots above it, and the rail's visibility taken
119
+ * from the address the same way the layout takes it.
120
+ */
121
+ function Shell({ width }: { width: number }) {
122
+ const intelligenceOpen = useOpenPanels().includes("intelligence");
123
+ if (width < 1024) {
124
+ return createElement(AppShellSlotted, {
125
+ initialWidth: width,
126
+ nav: null,
127
+ list: createElement(MailboxPane.Phone),
128
+ });
129
+ }
130
+ return createElement(AppShellSlotted, {
131
+ initialWidth: width,
132
+ nav: null,
133
+ list: null,
134
+ reading: createElement(MailboxPane.Reading),
135
+ intelligence: createElement(MailboxPane.Intelligence),
136
+ intelligenceOpen,
137
+ });
138
+ }
139
+
140
+ const testRouter = (width: number, href: string): AnyRouter => {
141
+ const rootRoute = createRootRoute({
142
+ component: () =>
143
+ createElement(
144
+ ComposeProvider,
145
+ null,
146
+ createElement(MailLayout, {
147
+ // biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
148
+ children: createElement(Outlet),
149
+ }),
150
+ ),
151
+ });
152
+ const mailRoute = createRoute({
153
+ getParentRoute: () => rootRoute,
154
+ path: "/mail",
155
+ validateSearch: (search: Record<string, unknown>) => search,
156
+ component: Outlet,
157
+ });
158
+ const mailboxRoute = createRoute({
159
+ getParentRoute: () => mailRoute,
160
+ path: "/$mailboxId",
161
+ component: () =>
162
+ createElement(MailboxPane, {
163
+ mailboxId: MAILBOX_ID,
164
+ thread: useOpenThreadPath(),
165
+ // biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
166
+ children: createElement(Outlet),
167
+ }),
168
+ });
169
+ const threadRoute = createRoute({
170
+ getParentRoute: () => mailboxRoute,
171
+ path: "$threadId",
172
+ component: Outlet,
173
+ });
174
+ const messageRoute = createRoute({
175
+ getParentRoute: () => threadRoute,
176
+ path: "$messageId",
177
+ component: () => createElement(Shell, { width }),
178
+ });
179
+ const routeTree = rootRoute.addChildren([
180
+ mailRoute.addChildren([
181
+ mailboxRoute.addChildren([threadRoute.addChildren([messageRoute])]),
182
+ ]),
183
+ ]);
184
+ return createRouter({
185
+ routeTree,
186
+ history: createMemoryHistory({ initialEntries: [href] }),
187
+ }) as unknown as AnyRouter;
188
+ };
189
+
190
+ const mountAt = async (
191
+ width: number,
192
+ options: DomOptions = {},
193
+ href: string = MESSAGE_PATH,
194
+ ): Promise<[DomHarness, AnyRouter]> => {
195
+ http = mockFetch((call) => {
196
+ if (call.path.endsWith("/config")) return { accounts: [] };
197
+ if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
198
+ return { items: [row] };
199
+ }
200
+ if (call.path.includes("/messages/")) return describedMessage;
201
+ if (call.path.includes("/threads")) return { items: [row] };
202
+ return { items: [] };
203
+ });
204
+
205
+ const router = testRouter(width, href);
206
+ await router.load();
207
+ const mounted = createDomHarness({ viewportWidth: width, ...options });
208
+ harness = mounted;
209
+ mounted.renderApp(createElement(RouterProvider, { router }));
210
+ await settle(mounted);
211
+ return [mounted, router];
212
+ };
213
+
214
+ const press = async (mounted: DomHarness, key: string): Promise<void> => {
215
+ mounted.dispatch(
216
+ mounted.window,
217
+ new mounted.window.KeyboardEvent("keydown", { key, bubbles: true }),
218
+ );
219
+ await settle(mounted);
220
+ };
221
+
222
+ /**
223
+ * The rail: the intelligence panel mounted in the shell's own row rather than
224
+ * inside the drawer, which puts the same panel behind a scrim.
225
+ */
226
+ const rail = (mounted: DomHarness): HTMLElement | null => {
227
+ const pane = mounted.query("aside");
228
+ if (!pane) return null;
229
+ return pane.closest('[role="dialog"]') ? null : pane;
230
+ };
231
+
232
+ describe("the intelligence keys reach the surface the width has (#840)", () => {
233
+ for (const [tier, width, options] of [
234
+ ["between the reading pane and the rail", TWO_PANE_WIDTH, {}],
235
+ [
236
+ "on the phone",
237
+ PHONE_WIDTH,
238
+ { pointer: "coarse", orientation: "portrait" } as DomOptions,
239
+ ],
240
+ ] as const) {
241
+ it(`${tier}, i opens the drawer and leaves the address alone`, async () => {
242
+ const [mounted, router] = await mountAt(width, options);
243
+
244
+ await press(mounted, "i");
245
+
246
+ assert.ok(intelligenceDrawer(mounted), "pressing i opened nothing");
247
+ assert.equal(
248
+ router.state.location.hash,
249
+ "",
250
+ "the address named a panel this width cannot render",
251
+ );
252
+ });
253
+
254
+ it(`${tier}, a second i puts the drawer away`, async () => {
255
+ const [mounted] = await mountAt(width, options);
256
+
257
+ await press(mounted, "i");
258
+ assert.ok(intelligenceDrawer(mounted), "pressing i opened nothing");
259
+
260
+ await press(mounted, "i");
261
+ assert.equal(
262
+ intelligenceDrawer(mounted),
263
+ null,
264
+ "the second press left it up",
265
+ );
266
+ });
267
+
268
+ it(`${tier}, b reaches block sender through the same drawer`, async () => {
269
+ const [mounted, router] = await mountAt(width, options);
270
+
271
+ await press(mounted, "b");
272
+
273
+ assert.ok(intelligenceDrawer(mounted), "pressing b opened nothing");
274
+ assert.equal(
275
+ router.state.location.hash,
276
+ "",
277
+ "the address named a panel this width cannot render",
278
+ );
279
+ });
280
+ }
281
+
282
+ it("still writes the fragment where the rail is the surface", async () => {
283
+ const [mounted, router] = await mountAt(RAIL_WIDTH);
284
+
285
+ await press(mounted, "i");
286
+
287
+ assert.equal(
288
+ router.state.location.hash,
289
+ "intelligence",
290
+ "the rail's own tier stopped writing the fragment",
291
+ );
292
+ assert.equal(
293
+ intelligenceDrawer(mounted),
294
+ null,
295
+ "the drawer came up where the rail is the surface",
296
+ );
297
+ });
298
+
299
+ it("writes the fragment for block sender where the rail is the surface", async () => {
300
+ const [mounted, router] = await mountAt(RAIL_WIDTH);
301
+
302
+ await press(mounted, "b");
303
+
304
+ assert.equal(router.state.location.hash, "intelligence");
305
+ assert.equal(
306
+ intelligenceDrawer(mounted),
307
+ null,
308
+ "the drawer came up where the rail is the surface",
309
+ );
310
+ });
311
+ });
312
+
313
+ /**
314
+ * What the fragment is worth at each width, on a cold load. The rail is the one
315
+ * renderer the name has, which is why the keys above must not write the name
316
+ * anywhere else.
317
+ */
318
+ describe("#intelligence names a renderer only where the rail fits", () => {
319
+ it("mounts the rail where the address carries it and the width has room", async () => {
320
+ const [mounted] = await mountAt(
321
+ RAIL_WIDTH,
322
+ {},
323
+ `${MESSAGE_PATH}#intelligence`,
324
+ );
325
+
326
+ assert.ok(rail(mounted), "the fragment raised no rail");
327
+ });
328
+
329
+ it("mounts nothing for the same address below the rail's width", async () => {
330
+ const [mounted] = await mountAt(
331
+ TWO_PANE_WIDTH,
332
+ {},
333
+ `${MESSAGE_PATH}#intelligence`,
334
+ );
335
+
336
+ assert.equal(rail(mounted), null, "a rail rendered where none fits");
337
+ assert.equal(
338
+ intelligenceDrawer(mounted),
339
+ null,
340
+ "the fragment raised the drawer, which belongs to the thread",
341
+ );
342
+ });
343
+ });
@@ -32,15 +32,19 @@ import { type OpenThreadPath, useOpenThreadPath } from "@/routing";
32
32
  import { createDomHarness, type DomHarness } from "@/test-support/dom";
33
33
  import { makeThreadMessage } from "@/test-support/fixtures";
34
34
  import { type HttpMock, mockFetch } from "@/test-support/http";
35
+ import {
36
+ describedMessage,
37
+ intelligenceDrawer,
38
+ MESSAGE_ID,
39
+ settle,
40
+ THREAD_ID,
41
+ } from "@/test-support/intelligence-surface";
35
42
  import { BriefPane } from "./BriefPane";
36
43
  import { FlaggedPane } from "./FlaggedPane";
37
44
 
38
45
  /** Below the rail's 1280px gate, above the reading pane's 1024px one. */
39
46
  const TWO_PANE_WIDTH = 1100;
40
47
 
41
- const THREAD_ID = "thread-1";
42
- const MESSAGE_ID = "msg-1";
43
-
44
48
  const SHOW_INTELLIGENCE = "Show intelligence sidebar";
45
49
  const HIDE_INTELLIGENCE = "Hide intelligence sidebar";
46
50
 
@@ -60,24 +64,6 @@ const row = makeThreadMessage({
60
64
  },
61
65
  });
62
66
 
63
- /** What the reading pane reads each message's own headers and body from. */
64
- const describedMessage = {
65
- messageId: MESSAGE_ID,
66
- envelope: {
67
- from: [
68
- {
69
- addressId: "addr-1",
70
- name: "Mondial Relay",
71
- email: "delivery.notice@gmail.example",
72
- },
73
- ],
74
- to: [],
75
- cc: [],
76
- bcc: [],
77
- },
78
- bodyParts: [],
79
- };
80
-
81
67
  let harness: DomHarness | undefined;
82
68
  let http: HttpMock | undefined;
83
69
 
@@ -197,15 +183,6 @@ const mount = async (pane: PaneUnderTest): Promise<DomHarness> => {
197
183
  return mounted;
198
184
  };
199
185
 
200
- const settle = async (mounted: DomHarness): Promise<void> => {
201
- await mounted.flush();
202
- await mounted.wait(20);
203
- await mounted.flush();
204
- };
205
-
206
- const drawer = (mounted: DomHarness): HTMLElement | null =>
207
- mounted.query('[role="dialog"][aria-label="Message details"]');
208
-
209
186
  describe("intelligence is reachable wherever the reading pane mounts (#817)", () => {
210
187
  for (const pane of panes) {
211
188
  it(`${pane.name} offers a live toolbar control below the rail's width`, async () => {
@@ -221,7 +198,7 @@ describe("intelligence is reachable wherever the reading pane mounts (#817)", ()
221
198
  mounted.click(toggle);
222
199
  await settle(mounted);
223
200
 
224
- assert.ok(drawer(mounted), "pressing it opened nothing");
201
+ assert.ok(intelligenceDrawer(mounted), "pressing it opened nothing");
225
202
  assert.ok(
226
203
  mounted.query(`[aria-label="${HIDE_INTELLIGENCE}"]`),
227
204
  "the toolbar still reports the surface as closed",
@@ -235,7 +212,7 @@ describe("intelligence is reachable wherever the reading pane mounts (#817)", ()
235
212
  await settle(mounted);
236
213
 
237
214
  assert.ok(
238
- drawer(mounted),
215
+ intelligenceDrawer(mounted),
239
216
  "the banner's Why? reached no intelligence surface",
240
217
  );
241
218
  });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The brief and Flagged are the two cross-account lists, and both offer Delete
3
+ * and Mark read over whatever is ticked (#872).
4
+ *
5
+ * The bulk endpoints refuse a batch spanning accounts before applying any of
6
+ * it, so a selection with one row from each account deleted nothing at all and
7
+ * marked nothing read — the user got "Couldn't delete these messages" and no
8
+ * mail moved. The split has to happen where the call is made, so this mounts
9
+ * the real hook against the real fetch seam and reads the requests that
10
+ * actually left: every batch carries one account, and between them they carry
11
+ * every ticked row.
12
+ *
13
+ * Which account each ticked row belongs to is the surfaces' half of the same
14
+ * fix, and it is pinned in `../lib/wizard-selection.test.ts`. How the run
15
+ * sequences the batches it splits into — progress over the whole selection,
16
+ * cancellation at whichever boundary comes next — is `../lib/bulk-actions.test.ts`,
17
+ * where a batch is a value rather than a request in flight.
18
+ */
19
+
20
+ import assert from "node:assert/strict";
21
+ import { afterEach, beforeEach, describe, it } from "node:test";
22
+ import { act, createElement } from "react";
23
+ import type { BulkActionTarget } from "../lib/bulk-actions";
24
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
25
+ import { type HttpMock, httpError, mockFetch } from "../test-support/http";
26
+ import {
27
+ type EscalatedAction,
28
+ type UseEscalatedActionsResult,
29
+ useEscalatedActions,
30
+ } from "./useEscalatedActions";
31
+
32
+ const ACCOUNT_A = "acc-work";
33
+ const ACCOUNT_B = "acc-personal";
34
+
35
+ /** A brief selection: two rows from one account, one from another. */
36
+ const MIXED: BulkActionTarget[] = [
37
+ { id: "msg-work-1", accountId: ACCOUNT_A },
38
+ { id: "msg-personal-1", accountId: ACCOUNT_B },
39
+ { id: "msg-work-2", accountId: ACCOUNT_A },
40
+ ];
41
+
42
+ let harness: DomHarness | undefined;
43
+ let http: HttpMock;
44
+
45
+ const mountRunner = (): (() => UseEscalatedActionsResult) => {
46
+ let value: UseEscalatedActionsResult | undefined;
47
+ const Probe = () => {
48
+ value = useEscalatedActions({
49
+ // The brief's selection belongs to no single mailbox, which is how the
50
+ // wizard mounts this hook over a cross-account list.
51
+ mailboxId: "",
52
+ enabled: false,
53
+ predicateKey: "selection-wizard",
54
+ searchQuery: {},
55
+ });
56
+ return null;
57
+ };
58
+ harness = createDomHarness();
59
+ harness.renderApp(createElement(Probe));
60
+ return () => {
61
+ if (!value) throw new Error("hook did not render");
62
+ return value;
63
+ };
64
+ };
65
+
66
+ /** Enough turns for a run of sequential batches to finish. */
67
+ const settle = async (): Promise<void> => {
68
+ if (!harness) throw new Error("nothing mounted");
69
+ for (let round = 0; round < 10; round += 1) await harness.flush();
70
+ };
71
+
72
+ const run = async (
73
+ hook: () => UseEscalatedActionsResult,
74
+ action: EscalatedAction,
75
+ targets: readonly BulkActionTarget[],
76
+ ) => {
77
+ let started: ReturnType<UseEscalatedActionsResult["runAction"]> | undefined;
78
+ act(() => {
79
+ started = hook().runAction(action, targets);
80
+ });
81
+ await settle();
82
+ if (!started) throw new Error("the run never started");
83
+ return started;
84
+ };
85
+
86
+ /** The message-id list of every bulk request that left, in order. */
87
+ const batches = (suffix: string): string[][] =>
88
+ http
89
+ .to(suffix)
90
+ .map((call) => (call.body as { messageIds?: string[] })?.messageIds ?? []);
91
+
92
+ const accountOf = (id: string): string | undefined =>
93
+ MIXED.find((target) => target.id === id)?.accountId;
94
+
95
+ const assertOneBatchPerAccount = (sent: string[][]): void => {
96
+ assert.equal(sent.length, 2, "the selection was not split by account");
97
+ for (const batch of sent) {
98
+ assert.equal(
99
+ new Set(batch.map(accountOf)).size,
100
+ 1,
101
+ "a batch spanned accounts, which the endpoint refuses whole",
102
+ );
103
+ }
104
+ assert.deepEqual(
105
+ [...sent.flat()].sort(),
106
+ MIXED.map((target) => target.id).sort(),
107
+ "a ticked row was never sent",
108
+ );
109
+ };
110
+
111
+ beforeEach(() => {
112
+ http = mockFetch(() => ({ successCount: 1, failureCount: 0 }));
113
+ });
114
+
115
+ afterEach(() => {
116
+ harness?.close();
117
+ harness = undefined;
118
+ http.restore();
119
+ });
120
+
121
+ describe("a selection spanning accounts", () => {
122
+ it("deletes every ticked row, one batch per account", async () => {
123
+ const hook = mountRunner();
124
+
125
+ const outcome = await run(hook, { kind: "delete" }, MIXED);
126
+
127
+ assertOneBatchPerAccount(batches("/messages/delete"));
128
+ assert.equal(outcome.done, MIXED.length);
129
+ assert.deepEqual(outcome.failedIds, []);
130
+ });
131
+
132
+ it("marks every ticked row read, one batch per account", async () => {
133
+ const hook = mountRunner();
134
+
135
+ const outcome = await run(hook, { kind: "markRead" }, MIXED);
136
+
137
+ assertOneBatchPerAccount(batches("/messages/flags"));
138
+ for (const call of http.calls) {
139
+ assert.equal((call.body as { isRead?: boolean })?.isRead, true);
140
+ }
141
+ assert.equal(outcome.done, MIXED.length);
142
+ });
143
+
144
+ it("reports how far it got when one account's batch fails", async () => {
145
+ http.restore();
146
+ http = mockFetch((call) =>
147
+ (call.body as { messageIds?: string[] })?.messageIds?.[0] ===
148
+ "msg-personal-1"
149
+ ? httpError(409, "mailbox is locked")
150
+ : { successCount: 1, failureCount: 0 },
151
+ );
152
+ const hook = mountRunner();
153
+
154
+ const outcome = await run(hook, { kind: "delete" }, MIXED);
155
+
156
+ assert.equal(outcome.done, 2, "the account that succeeded is not counted");
157
+ assert.deepEqual(
158
+ outcome.failedIds,
159
+ ["msg-personal-1"],
160
+ "the untouched rows are not handed back to retry",
161
+ );
162
+ assert.notEqual(outcome.error, undefined);
163
+ });
164
+ });
@@ -17,6 +17,7 @@ import {
17
17
  import {
18
18
  type ApplyBatch,
19
19
  type BulkActionProgress,
20
+ type BulkActionTarget,
20
21
  type BulkRunOutcome,
21
22
  type FetchIdsPage,
22
23
  honestProgress,
@@ -100,18 +101,21 @@ export interface UseEscalatedActionsResult {
100
101
  runningAction: EscalatedAction | undefined;
101
102
  progress: BulkActionProgress | undefined;
102
103
  /**
103
- * Runs `action` in chunks. Pass `ids` for a materialized (bounded)
104
- * selection; omit it to run against the escalated predicate (`phase` must
105
- * be "escalated"). Resolves once the run ends for any reason — cancelled,
106
- * errored, or complete with a `done`/`failedIds` outcome the caller reads
107
- * to decide what is still outstanding.
104
+ * Runs `action` in chunks. Pass `targets` for a materialized (bounded)
105
+ * selection; omit them to run against the escalated predicate (`phase` must
106
+ * be "escalated"). Each target names the account that owns it, so a
107
+ * selection spanning accounts is sent as one batch per account rather than
108
+ * as one batch the endpoint refuses whole (#872). Resolves once the run ends
109
+ * for any reason — cancelled, errored, or complete — with a
110
+ * `done`/`failedIds` outcome the caller reads to decide what is still
111
+ * outstanding.
108
112
  * Infrastructure failures are reported through the app's existing
109
113
  * escalation seam (`pushError`, which itself escalates a 5xx/exception to
110
114
  * the fatal overlay) — not swallowed here.
111
115
  */
112
116
  runAction: (
113
117
  action: EscalatedAction,
114
- ids?: string[],
118
+ targets?: readonly BulkActionTarget[],
115
119
  ) => Promise<BulkRunOutcome>;
116
120
  }
117
121
 
@@ -225,16 +229,26 @@ export const useEscalatedActions = ({
225
229
  [],
226
230
  );
227
231
 
232
+ /**
233
+ * The unseen counts a run moved, per account. A cross-account selection has
234
+ * no single owning account — the surface leaves the option undefined exactly
235
+ * then — so the run's own targets are what name the accounts to refresh.
236
+ */
228
237
  const invalidateAfterRun = useCallback(
229
- (action: EscalatedAction) => {
238
+ (action: EscalatedAction, targets: readonly BulkActionTarget[]) => {
230
239
  invalidateThreadListQueries(
231
240
  queryClient,
232
241
  threadListCacheKeys(mailboxesTouchedBy(action, mailboxId)),
233
242
  );
234
- if (accountId) {
243
+ const touched = new Set<string>();
244
+ if (accountId) touched.add(accountId);
245
+ for (const target of targets) {
246
+ if (target.accountId) touched.add(target.accountId);
247
+ }
248
+ for (const touchedAccountId of touched) {
235
249
  queryClient.invalidateQueries({
236
250
  queryKey: mailboxOperationsListMailboxesQueryKey({
237
- path: { accountId },
251
+ path: { accountId: touchedAccountId },
238
252
  }),
239
253
  });
240
254
  }
@@ -279,7 +293,7 @@ export const useEscalatedActions = ({
279
293
  const runAction = useCallback(
280
294
  async (
281
295
  action: EscalatedAction,
282
- ids?: string[],
296
+ targets?: readonly BulkActionTarget[],
283
297
  ): Promise<BulkRunOutcome> => {
284
298
  cancelRef.current = false;
285
299
  runningRef.current = true;
@@ -301,9 +315,9 @@ export const useEscalatedActions = ({
301
315
  let outcome: BulkRunOutcome;
302
316
  try {
303
317
  outcome =
304
- ids !== undefined
318
+ targets !== undefined
305
319
  ? await runChunkedAction(
306
- ids,
320
+ targets,
307
321
  applyBatch,
308
322
  onProgress,
309
323
  () => cancelRef.current,
@@ -333,7 +347,7 @@ export const useEscalatedActions = ({
333
347
  );
334
348
  }
335
349
  if (outcome.done > 0) {
336
- invalidateAfterRun(action);
350
+ invalidateAfterRun(action, targets ?? []);
337
351
  }
338
352
  return outcome;
339
353
  },