@remit/web-client 0.0.94 → 0.0.96

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.
@@ -26,12 +26,15 @@ import {
26
26
 
27
27
  let harness: DomHarness | undefined;
28
28
  let http: HttpMock | undefined;
29
+ let restoreFetch: (() => void) | undefined;
29
30
 
30
31
  afterEach(() => {
31
32
  harness?.close();
32
33
  harness = undefined;
33
34
  http?.restore();
34
35
  http = undefined;
36
+ restoreFetch?.();
37
+ restoreFetch = undefined;
35
38
  });
36
39
 
37
40
  interface MailboxProgress {
@@ -72,22 +75,83 @@ const accountOf = (path: string): string => path.split("/")[2] ?? "";
72
75
  const progress = (dom: DomHarness): InitialSyncProgress =>
73
76
  JSON.parse(dom.text());
74
77
 
75
- const mount = async (
78
+ const UNKNOWN: InitialSyncProgress = {
79
+ syncing: false,
80
+ resolved: false,
81
+ synced: 0,
82
+ total: 0,
83
+ };
84
+
85
+ /** How long the hook gets to answer a request that is already in hand. */
86
+ const RESOLVE_CEILING_MS = 2000;
87
+ /** How long the hook is watched for an answer, or a request, it must not give. */
88
+ const QUIET_WINDOW_MS = 500;
89
+ const STEP_MS = 10;
90
+
91
+ /**
92
+ * Let real time pass until the hook itself says `done`, or the ceiling runs out.
93
+ *
94
+ * Every wait in this file is a question put to the hook rather than a count of
95
+ * turns or a sleep the answer is assumed to fit inside: the poll and the answer
96
+ * deadline run on real timers, so a loaded machine changes how much work lands
97
+ * per turn, and the ceiling is what keeps a hook that never answers a failure
98
+ * instead of a hang.
99
+ */
100
+ const settleUntil = async (
101
+ dom: DomHarness,
102
+ done: () => boolean,
103
+ ceilingMs: number,
104
+ ): Promise<void> => {
105
+ const deadline = Date.now() + ceilingMs;
106
+ for (;;) {
107
+ await dom.flush();
108
+ if (done() || Date.now() >= deadline) return;
109
+ await dom.wait(STEP_MS);
110
+ }
111
+ };
112
+
113
+ const mount = (
76
114
  accountIds: string[],
77
115
  answer: (accountId: string) => unknown,
78
116
  enabled = true,
79
- ): Promise<DomHarness> => {
117
+ ): DomHarness => {
80
118
  http = mockFetch((call) => answer(accountOf(call.path)));
81
119
  harness = createDomHarness();
82
120
  harness.renderApp(createElement(Probe, { accountIds, enabled }));
83
- // Settle on the hook's own answer rather than on a fixed number of turns:
84
- // a query that lands on a timer instead of a microtask needs real time to
85
- // pass, and a loaded machine is where that difference shows up.
86
- for (let turn = 0; turn < 20; turn += 1) {
87
- await harness.flush();
88
- if (progress(harness).resolved) break;
89
- await harness.wait(1);
90
- }
121
+ return harness;
122
+ };
123
+
124
+ const mountResolved = async (
125
+ accountIds: string[],
126
+ answer: (accountId: string) => unknown,
127
+ ): Promise<DomHarness> => {
128
+ const dom = mount(accountIds, answer);
129
+ await settleUntil(dom, () => progress(dom).resolved, RESOLVE_CEILING_MS);
130
+ return dom;
131
+ };
132
+
133
+ /** `acc-2` never answers at all: its request is left hanging for good. */
134
+ const mountSilentAccount = (
135
+ answerOfAccountOne: RemitImapAccountSyncStatusResponse,
136
+ ): DomHarness => {
137
+ const original = globalThis.fetch;
138
+ restoreFetch = () => {
139
+ globalThis.fetch = original;
140
+ };
141
+ globalThis.fetch = ((input: RequestInfo | URL) => {
142
+ const url = input instanceof Request ? input.url : String(input);
143
+ if (url.includes("acc-2")) return new Promise<Response>(() => {});
144
+ return Promise.resolve(
145
+ new Response(JSON.stringify(answerOfAccountOne), {
146
+ status: 200,
147
+ headers: { "content-type": "application/json" },
148
+ }),
149
+ );
150
+ }) as typeof globalThis.fetch;
151
+ harness = createDomHarness();
152
+ harness.renderApp(
153
+ createElement(Probe, { accountIds: ["acc-1", "acc-2"], enabled: true }),
154
+ );
91
155
  return harness;
92
156
  };
93
157
 
@@ -112,7 +176,7 @@ describe("isSyncingPhase", () => {
112
176
 
113
177
  describe("useInitialSyncProgress", () => {
114
178
  it("reports syncing with the counts of the accounts still syncing", async () => {
115
- const dom = await mount(["acc-1"], (accountId) =>
179
+ const dom = await mountResolved(["acc-1"], (accountId) =>
116
180
  status(accountId, "syncing_inbox", [
117
181
  { synced: 40, total: 100 },
118
182
  { synced: 10, total: 60 },
@@ -127,7 +191,7 @@ describe("useInitialSyncProgress", () => {
127
191
  });
128
192
 
129
193
  it("leaves out the counts of an account that has finished", async () => {
130
- const dom = await mount(["acc-1", "acc-2"], (accountId) =>
194
+ const dom = await mountResolved(["acc-1", "acc-2"], (accountId) =>
131
195
  accountId === "acc-1"
132
196
  ? status(accountId, "syncing_inbox", [{ synced: 40, total: 100 }])
133
197
  : status(accountId, "complete", [{ synced: 900, total: 900 }]),
@@ -141,7 +205,7 @@ describe("useInitialSyncProgress", () => {
141
205
  });
142
206
 
143
207
  it("resolves to not-syncing once every account is done", async () => {
144
- const dom = await mount(["acc-1", "acc-2"], (accountId) =>
208
+ const dom = await mountResolved(["acc-1", "acc-2"], (accountId) =>
145
209
  status(accountId, "complete", [{ synced: 900, total: 900 }]),
146
210
  );
147
211
  assert.deepEqual(progress(dom), {
@@ -153,7 +217,7 @@ describe("useInitialSyncProgress", () => {
153
217
  });
154
218
 
155
219
  it("counts an unreachable account as answered rather than holding the list in limbo", async () => {
156
- const dom = await mount(["acc-1", "acc-2"], (accountId) =>
220
+ const dom = await mountResolved(["acc-1", "acc-2"], (accountId) =>
157
221
  accountId === "acc-1"
158
222
  ? status(accountId, "syncing_inbox", [{ synced: 5, total: 50 }])
159
223
  : httpError(503),
@@ -167,39 +231,17 @@ describe("useInitialSyncProgress", () => {
167
231
  it("knows nothing until every account has answered", async () => {
168
232
  // One account answers, the other never does — the hook must not report on
169
233
  // the half it has.
170
- const original = globalThis.fetch;
171
- try {
172
- globalThis.fetch = ((input: RequestInfo | URL) => {
173
- const url = input instanceof Request ? input.url : String(input);
174
- if (url.includes("acc-2")) return new Promise<Response>(() => {});
175
- return Promise.resolve(
176
- new Response(
177
- JSON.stringify(
178
- status("acc-1", "syncing_inbox", [{ synced: 5, total: 50 }]),
179
- ),
180
- { status: 200, headers: { "content-type": "application/json" } },
181
- ),
182
- );
183
- }) as typeof globalThis.fetch;
184
- harness = createDomHarness();
185
- harness.renderApp(
186
- createElement(Probe, { accountIds: ["acc-1", "acc-2"], enabled: true }),
187
- );
188
- await harness.flush();
189
- await harness.flush();
190
- assert.deepEqual(progress(harness), {
191
- syncing: false,
192
- resolved: false,
193
- synced: 0,
194
- total: 0,
195
- });
196
- } finally {
197
- globalThis.fetch = original;
198
- }
234
+ const dom = mountSilentAccount(
235
+ status("acc-1", "syncing_inbox", [{ synced: 5, total: 50 }]),
236
+ );
237
+
238
+ await settleUntil(dom, () => progress(dom).resolved, QUIET_WINDOW_MS);
239
+
240
+ assert.deepEqual(progress(dom), UNKNOWN);
199
241
  });
200
242
 
201
243
  it("answers immediately, and never syncing, for no accounts at all", async () => {
202
- const dom = await mount([], () => ({}));
244
+ const dom = await mountResolved([], () => ({}));
203
245
  assert.deepEqual(progress(dom), {
204
246
  syncing: false,
205
247
  resolved: true,
@@ -211,24 +253,28 @@ describe("useInitialSyncProgress", () => {
211
253
 
212
254
  it("stops asking once every account has answered and none is syncing", async () => {
213
255
  // The caught-up user's answer cannot change by being asked again.
214
- const dom = await mount(["acc-1", "acc-2"], (accountId) =>
256
+ const dom = await mountResolved(["acc-1", "acc-2"], (accountId) =>
215
257
  status(accountId, "complete"),
216
258
  );
217
259
  const asked = http?.calls.length ?? 0;
218
260
  assert.equal(asked, 2);
219
261
 
220
- await dom.wait(POLL_MS + 500);
262
+ await settleUntil(
263
+ dom,
264
+ () => (http?.calls.length ?? 0) > asked,
265
+ POLL_MS + QUIET_WINDOW_MS,
266
+ );
221
267
 
222
268
  assert.equal(http?.calls.length, asked);
223
269
  assert.equal(progress(dom).resolved, true);
224
270
  });
225
271
 
226
272
  it("keeps asking while an account is still syncing", async () => {
227
- const dom = await mount(["acc-1"], (accountId) =>
273
+ const dom = await mountResolved(["acc-1"], (accountId) =>
228
274
  status(accountId, "syncing_inbox", [{ synced: 5, total: 50 }]),
229
275
  );
230
276
 
231
- await dom.wait(POLL_MS + 500);
277
+ await settleUntil(dom, () => (http?.calls.length ?? 0) > 1, POLL_MS * 3);
232
278
 
233
279
  assert.ok(
234
280
  (http?.calls.length ?? 0) > 1,
@@ -237,50 +283,31 @@ describe("useInitialSyncProgress", () => {
237
283
  });
238
284
 
239
285
  it("answers without an account that never responds", async () => {
240
- const original = globalThis.fetch;
241
- try {
242
- globalThis.fetch = ((input: RequestInfo | URL) => {
243
- const url = input instanceof Request ? input.url : String(input);
244
- if (url.includes("acc-2")) return new Promise<Response>(() => {});
245
- return Promise.resolve(
246
- new Response(JSON.stringify(status("acc-1", "complete")), {
247
- status: 200,
248
- headers: { "content-type": "application/json" },
249
- }),
250
- );
251
- }) as typeof globalThis.fetch;
252
- harness = createDomHarness();
253
- harness.renderApp(
254
- createElement(Probe, { accountIds: ["acc-1", "acc-2"], enabled: true }),
255
- );
256
- await harness.flush();
257
- assert.equal(progress(harness).resolved, false);
258
-
259
- await harness.wait(ANSWER_DEADLINE_MS + 500);
260
-
261
- assert.deepEqual(progress(harness), {
262
- syncing: false,
263
- resolved: true,
264
- synced: 0,
265
- total: 0,
266
- });
267
- } finally {
268
- globalThis.fetch = original;
269
- }
270
- });
286
+ const dom = mountSilentAccount(status("acc-1", "complete"));
271
287
 
272
- it("asks nothing and claims nothing while disabled", async () => {
273
- const dom = await mount(
274
- ["acc-1"],
275
- () => status("acc-1", "syncing_inbox"),
276
- false,
288
+ await settleUntil(dom, () => progress(dom).resolved, QUIET_WINDOW_MS);
289
+ assert.equal(progress(dom).resolved, false);
290
+
291
+ await settleUntil(
292
+ dom,
293
+ () => progress(dom).resolved,
294
+ ANSWER_DEADLINE_MS * 2,
277
295
  );
296
+
278
297
  assert.deepEqual(progress(dom), {
279
298
  syncing: false,
280
- resolved: false,
299
+ resolved: true,
281
300
  synced: 0,
282
301
  total: 0,
283
302
  });
303
+ });
304
+
305
+ it("asks nothing and claims nothing while disabled", async () => {
306
+ const dom = mount(["acc-1"], () => status("acc-1", "syncing_inbox"), false);
307
+
308
+ await settleUntil(dom, () => progress(dom).resolved, QUIET_WINDOW_MS);
309
+
310
+ assert.deepEqual(progress(dom), UNKNOWN);
284
311
  assert.deepEqual(http?.calls ?? [], []);
285
312
  });
286
313
  });
@@ -1,7 +1,14 @@
1
- import { messageOperationsDescribeMessageOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import type { RemitImapDescribeMessageResponse } from "@remit/api-http-client/types.gen.ts";
1
+ import {
2
+ messageOperationsDescribeMessageOptions,
3
+ threadOperationsSearchThreadsOptions,
4
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
+ import type {
6
+ RemitImapDescribeMessageResponse,
7
+ RemitImapThreadMessageResponse,
8
+ } from "@remit/api-http-client/types.gen.ts";
3
9
  import { senderLabel, type WizardMessage } from "@remit/ui";
4
- import { useQueries } from "@tanstack/react-query";
10
+ import { useQueries, useQuery } from "@tanstack/react-query";
11
+ import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
5
12
  import { formatEmailDate } from "@/lib/format";
6
13
 
7
14
  /**
@@ -44,6 +51,40 @@ const toWizardMessage = (
44
51
  };
45
52
  };
46
53
 
54
+ /**
55
+ * The members of an escalated predicate's match (#508). The predicate is the
56
+ * search the list is showing, so the search endpoint that resolved it is what
57
+ * names its members — one bounded request rather than the browser's own loaded
58
+ * rows, which stop at the page the list happens to have reached.
59
+ */
60
+ export const useSearchMatchSample = (
61
+ mailboxId: string | undefined,
62
+ query: EscalationSearchQuery | undefined,
63
+ ): MatchSample => {
64
+ const enabled = mailboxId !== undefined && query !== undefined;
65
+ const { data, isLoading } = useQuery({
66
+ ...threadOperationsSearchThreadsOptions({
67
+ path: { mailboxId: mailboxId ?? "" },
68
+ query: { ...query, limit: SAMPLE_LIMIT },
69
+ }),
70
+ enabled,
71
+ staleTime: 30_000,
72
+ });
73
+ return {
74
+ messages: (data?.items ?? []).map(toSearchWizardMessage),
75
+ isPending: enabled && isLoading,
76
+ };
77
+ };
78
+
79
+ const toSearchWizardMessage = (
80
+ row: RemitImapThreadMessageResponse,
81
+ ): WizardMessage => ({
82
+ id: row.messageId,
83
+ sender: row.fromName || row.fromEmail || "Unknown",
84
+ subject: row.subject ?? "(No subject)",
85
+ date: formatEmailDate(row.sentDate),
86
+ });
87
+
47
88
  export const useMatchSample = (messageIds: readonly string[]): MatchSample =>
48
89
  useQueries({
49
90
  queries: messageIds.slice(0, SAMPLE_LIMIT).map((messageId) => ({
@@ -5,7 +5,6 @@ import {
5
5
  bulkActionCompletionText,
6
6
  bulkActionFailureDetail,
7
7
  bulkActionFailureTitle,
8
- bulkActionPartialText,
9
8
  bulkActionProgressLabel,
10
9
  bulkActionProgressTone,
11
10
  } from "./bulk-action-copy.js";
@@ -46,23 +45,6 @@ describe("bulkActionCompletionText", () => {
46
45
  });
47
46
  });
48
47
 
49
- describe("bulkActionPartialText", () => {
50
- test("splits what landed from what is still selected", () => {
51
- assert.equal(
52
- bulkActionPartialText("delete", 3072, 340),
53
- "3,072 moved to Trash. 340 couldn't be deleted.",
54
- );
55
- assert.equal(
56
- bulkActionPartialText("move", 3072, 340),
57
- "3,072 moved. 340 couldn't be moved.",
58
- );
59
- assert.equal(
60
- bulkActionPartialText("markRead", 3072, 340),
61
- "3,072 marked as read. 340 couldn't be marked as read.",
62
- );
63
- });
64
- });
65
-
66
48
  describe("bulkActionFailureTitle", () => {
67
49
  test("reports where a partly-done run stopped", () => {
68
50
  assert.equal(
@@ -87,7 +69,6 @@ describe("every action carries its own wording", () => {
87
69
  test("no two actions share a sentence", () => {
88
70
  const sentences: Array<(kind: BulkActionKind) => string> = [
89
71
  (kind) => bulkActionCompletionText(kind, 5),
90
- (kind) => bulkActionPartialText(kind, 5, 2),
91
72
  (kind) => bulkActionFailureTitle(kind, 0),
92
73
  (kind) => bulkActionFailureTitle(kind, 5),
93
74
  bulkActionFailureDetail,
@@ -59,14 +59,6 @@ export const bulkActionCompletionText = (
59
59
  ): string =>
60
60
  `${formatNumber(done)} ${pastTense[kind]}. Your mail server is still catching up.`;
61
61
 
62
- /** Shown when part of a run landed and the rest is still selected for Retry. */
63
- export const bulkActionPartialText = (
64
- kind: BulkActionKind,
65
- succeeded: number,
66
- remaining: number,
67
- ): string =>
68
- `${formatNumber(succeeded)} ${pastTense[kind]}. ${formatNumber(remaining)} ${negated[kind]}.`;
69
-
70
62
  /** Error-banner title for a run stopped by an infrastructure failure. */
71
63
  export const bulkActionFailureTitle = (
72
64
  kind: BulkActionKind,
@@ -6,7 +6,6 @@ import {
6
6
  countMatches,
7
7
  type FetchIdsPageResult,
8
8
  honestProgress,
9
- resolveSelectionAfterRun,
10
9
  runChunkedAction,
11
10
  runPredicateAction,
12
11
  } from "./bulk-actions.js";
@@ -355,65 +354,6 @@ describe("countMatches", () => {
355
354
  });
356
355
  });
357
356
 
358
- describe("resolveSelectionAfterRun", () => {
359
- test("a clean run with nothing failed exits selection mode", () => {
360
- assert.deepEqual(
361
- resolveSelectionAfterRun({
362
- done: 3412,
363
- failedIds: [],
364
- cancelled: false,
365
- }),
366
- { exit: true, retryIds: [] },
367
- );
368
- });
369
-
370
- test("unreached ids stay selected for a precise retry, even alongside a clean stop", () => {
371
- assert.deepEqual(
372
- resolveSelectionAfterRun({
373
- done: 3072,
374
- failedIds: ["a", "b"],
375
- cancelled: false,
376
- }),
377
- { exit: false, retryIds: ["a", "b"] },
378
- );
379
- });
380
-
381
- test("a clean cancel with nothing yet confirmed failed leaves nothing to retry, but does not exit", () => {
382
- assert.deepEqual(
383
- resolveSelectionAfterRun({
384
- done: 100,
385
- failedIds: [],
386
- cancelled: true,
387
- }),
388
- { exit: false, retryIds: [] },
389
- );
390
- });
391
-
392
- test("an infra failure with nothing left unreached still keeps selection mode open", () => {
393
- assert.deepEqual(
394
- resolveSelectionAfterRun({
395
- done: 0,
396
- failedIds: [],
397
- cancelled: false,
398
- error: new Error("network blip"),
399
- }),
400
- { exit: false, retryIds: [] },
401
- );
402
- });
403
-
404
- test("failedIds wins over cancelled/error when both are present", () => {
405
- assert.deepEqual(
406
- resolveSelectionAfterRun({
407
- done: 10,
408
- failedIds: ["x"],
409
- cancelled: true,
410
- error: new Error("boom"),
411
- }),
412
- { exit: false, retryIds: ["x"] },
413
- );
414
- });
415
- });
416
-
417
357
  describe("honestProgress", () => {
418
358
  // Regression for #109: `countMatches` and `runPredicateAction` page the
419
359
  // same predicate independently, so the delete can outrun the frozen
@@ -275,29 +275,3 @@ export interface BulkRunOutcome {
275
275
  cancelled: boolean;
276
276
  error?: unknown;
277
277
  }
278
-
279
- export interface SelectionAfterRun {
280
- /** The action reached everything targeted — selection mode should exit. */
281
- exit: boolean;
282
- /** The bounded selection to leave in place, empty when exiting. */
283
- retryIds: string[];
284
- }
285
-
286
- /**
287
- * What a caller does with selection once a run ends, for any reason. Every id
288
- * the action never reached — a chunk the bounded run skipped because it was
289
- * stopped or errored — belongs in `retryIds`: it is exactly what Retry should
290
- * resend, and it is what stays selected so the count on screen never claims
291
- * more was done than actually was (#92 requirement 8).
292
- */
293
- export const resolveSelectionAfterRun = (
294
- outcome: BulkRunOutcome,
295
- ): SelectionAfterRun => {
296
- if (outcome.failedIds.length > 0) {
297
- return { exit: false, retryIds: outcome.failedIds };
298
- }
299
- if (outcome.cancelled || outcome.error) {
300
- return { exit: false, retryIds: [] };
301
- }
302
- return { exit: true, retryIds: [] };
303
- };
@@ -119,31 +119,4 @@ describe("formatDeleteToTrashTitle", () => {
119
119
  "Move 3,412 messages to Trash?",
120
120
  );
121
121
  });
122
-
123
- // #109: an escalated-predicate count is paged once by `countMatches` and
124
- // re-paged independently by the delete itself — never provably the number
125
- // that gets deleted. `isEstimate` says so instead of stating an exact
126
- // number the run may not honour.
127
- describe("isEstimate (#109 — an escalated-predicate count, not a materialized selection)", () => {
128
- test("prefixes 'about' for a plural estimate", () => {
129
- assert.strictEqual(
130
- formatDeleteToTrashTitle(3412, true),
131
- "Move about 3,412 messages to Trash?",
132
- );
133
- });
134
-
135
- test("prefixes 'about' for a singular estimate", () => {
136
- assert.strictEqual(
137
- formatDeleteToTrashTitle(1, true),
138
- "Move about 1 message to Trash?",
139
- );
140
- });
141
-
142
- test("defaults to false — a bounded selection's count stays exact", () => {
143
- assert.strictEqual(
144
- formatDeleteToTrashTitle(3412),
145
- formatDeleteToTrashTitle(3412, false),
146
- );
147
- });
148
- });
149
122
  });
package/src/lib/format.ts CHANGED
@@ -152,21 +152,11 @@ export const formatEmailDate = (date: Date | string | number): string => {
152
152
  /**
153
153
  * Confirmation title for the move-to-Trash delete flow. Reflects that delete
154
154
  * moves messages to Trash (not a permanent delete) and pluralizes on count.
155
- * Thousands-separated: at escalated-selection scale this is exactly the digit
156
- * count someone checks against what they meant to select.
157
- *
158
- * `isEstimate` marks an escalated-predicate count (#109): it was paged to a
159
- * total once, and the delete itself re-pages the same predicate independently
160
- * — mail arriving or leaving between the two can make them differ. "about"
161
- * says so up front instead of stating a number the run may not honour; a
162
- * materialized (bounded) selection's count is exact and never passes it.
155
+ * The count is always a concrete list of ids — every predicate delete ends on
156
+ * the wizard's review screen instead, which states what the predicate covers.
163
157
  */
164
- export const formatDeleteToTrashTitle = (
165
- count: number,
166
- isEstimate = false,
167
- ): string => {
158
+ export const formatDeleteToTrashTitle = (count: number): string => {
168
159
  const quantity = count === 1 ? "1" : formatNumber(count);
169
160
  const noun = count === 1 ? "message" : "messages";
170
- const prefix = isEstimate ? "about " : "";
171
- return `Move ${prefix}${quantity} ${noun} to Trash?`;
161
+ return `Move ${quantity} ${noun} to Trash?`;
172
162
  };
@@ -6,7 +6,7 @@ import type {
6
6
  RemitImapOrganizeInput,
7
7
  } from "@remit/api-http-client/types.gen.ts";
8
8
  import type {
9
- MatchMode,
9
+ MatchDoor,
10
10
  MatchOperator,
11
11
  RuleClause,
12
12
  RuleScope,
@@ -105,7 +105,13 @@ export const buildOrganizeInput = (
105
105
  * it holds, and the four `OrganizeScope` values fall out of the pair.
106
106
  */
107
107
  export interface WizardCommitAnswers {
108
- mode: MatchMode;
108
+ /**
109
+ * The door the match came through. An escalated predicate is not one: it has
110
+ * no clauses to build a rule from and no anchor to widen, so a scope
111
+ * reconstructed for it would be a rule matching everything. Typed to the
112
+ * three doors so that cannot be written rather than merely not written.
113
+ */
114
+ mode: MatchDoor;
109
115
  /** Absent on the verbs that never reach the scope step — those act once. */
110
116
  ruleScope?: RuleScope;
111
117
  }
@@ -17,9 +17,9 @@
17
17
  * rewinds every entry the wizard owns.
18
18
  */
19
19
 
20
- import { type StepId, stepIndex } from "@remit/ui";
20
+ import { type StepId, stepIndex, type Verb } from "@remit/ui";
21
21
  import { useNavigate, useRouter, useSearch } from "@tanstack/react-router";
22
- import { useCallback, useEffect, useRef } from "react";
22
+ import { useCallback, useEffect, useRef, useState } from "react";
23
23
  import { z } from "zod";
24
24
 
25
25
  const stepId = z.enum([
@@ -101,6 +101,46 @@ export const useOpenWizard = (): ((
101
101
  );
102
102
  };
103
103
 
104
+ export interface SelectionWizardControl {
105
+ /** The verb the wizard was opened for, for the host that renders it. */
106
+ verb: Verb;
107
+ /** Opens the wizard on a verb, from the match step — every bar verb, and
108
+ * every keyboard verb aimed at a selection, comes through here. */
109
+ start: (verb: Verb) => void;
110
+ /** Opens it on the search entry instead: the property step, with the query
111
+ * converted (#477 1.8). The route for a verb whose match is a predicate
112
+ * rather than a set of clauses. */
113
+ startFromSearch: () => void;
114
+ /**
115
+ * A step is held, so the wizard owns the screen. Every selection surface
116
+ * suspends its keyboard layer on this: a shortcut acting behind the screen
117
+ * already asking about an action is a second flow nobody asked for.
118
+ */
119
+ isOpen: boolean;
120
+ }
121
+
122
+ /**
123
+ * The wizard as a selection surface drives it. The step lives in the URL and
124
+ * the verb beside it in state, in one place rather than one copy per surface —
125
+ * three of them had drifted apart on which verbs they even offered.
126
+ */
127
+ export const useSelectionWizard = (): SelectionWizardControl => {
128
+ const step = useWizardStepValue();
129
+ const openWizard = useOpenWizard();
130
+ const [verb, setVerb] = useState<Verb>("organize");
131
+ const start = useCallback(
132
+ (next: Verb) => {
133
+ setVerb(next);
134
+ openWizard("match");
135
+ },
136
+ [openWizard],
137
+ );
138
+ const startFromSearch = useCallback(() => {
139
+ openWizard("properties", "search");
140
+ }, [openWizard]);
141
+ return { verb, start, startFromSearch, isOpen: step !== undefined };
142
+ };
143
+
104
144
  export interface WizardStepNavigation {
105
145
  step: StepId | undefined;
106
146
  goToStep: (step: StepId) => void;