@remit/ui 0.0.62 → 0.0.63

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/ui",
3
- "version": "0.0.62",
3
+ "version": "0.0.63",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -1,6 +1,6 @@
1
1
  import { Menu } from "lucide-react";
2
2
  import type { ReactNode } from "react";
3
- import { useRef, useState } from "react";
3
+ import { useMemo, useRef, useState } from "react";
4
4
  import { LIST_ROW_SELECTOR, useRovingFocus } from "../lib/roving-focus.js";
5
5
  import type { AppShellProps, TouchSeed } from "./app-shell-types.js";
6
6
  import { BriefSections } from "./brief-sections.js";
@@ -125,7 +125,27 @@ export function MessageListPane({
125
125
  ? new Set(seededRows.slice(0, 2).map((t) => t.id))
126
126
  : new Set(),
127
127
  );
128
+ // What the fallback bar's verbs have done to the mock rows. A demo bar whose
129
+ // Trash only closes the bar is a Trash that deletes nothing, which is the one
130
+ // thing a selection bar must never be — so these verbs act on the rows the
131
+ // mock owns, the same way `refresh` below fakes a refresh visibly.
132
+ const [trashedIds, setTrashedIds] = useState<ReadonlySet<string>>(new Set());
133
+ const [readIds, setReadIds] = useState<ReadonlySet<string>>(new Set());
128
134
  const [refreshing, setRefreshing] = useState(false);
135
+ const touchSections = useMemo(
136
+ () =>
137
+ trashedIds.size === 0 && readIds.size === 0
138
+ ? sections
139
+ : sections.map((section) => ({
140
+ ...section,
141
+ threads: section.threads
142
+ .filter((thread) => !trashedIds.has(thread.id))
143
+ .map((thread) =>
144
+ readIds.has(thread.id) ? { ...thread, isRead: true } : thread,
145
+ ),
146
+ })),
147
+ [sections, trashedIds, readIds],
148
+ );
129
149
  const initialPeek: SwipePeek | undefined =
130
150
  initialTouchState === "peek-trailing"
131
151
  ? "trailing"
@@ -150,6 +170,14 @@ export function MessageListPane({
150
170
  setSelectionMode(false);
151
171
  setCheckedIds(new Set());
152
172
  };
173
+ const trashChecked = () => {
174
+ setTrashedIds((prev) => new Set([...prev, ...checkedIds]));
175
+ cancelSelection();
176
+ };
177
+ const markCheckedRead = () => {
178
+ setReadIds((prev) => new Set([...prev, ...checkedIds]));
179
+ cancelSelection();
180
+ };
153
181
  const refresh = () => {
154
182
  setRefreshing(true);
155
183
  setTimeout(() => setRefreshing(false), 1400);
@@ -168,8 +196,8 @@ export function MessageListPane({
168
196
  title={listTitle}
169
197
  count={checkedIds.size}
170
198
  onCancel={cancelSelection}
171
- onMarkRead={cancelSelection}
172
- onDelete={cancelSelection}
199
+ onMarkRead={markCheckedRead}
200
+ onDelete={trashChecked}
173
201
  />
174
202
  ) : hideHeader ? null : (
175
203
  <header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
@@ -230,7 +258,7 @@ export function MessageListPane({
230
258
  listBody
231
259
  ) : touchTriage ? (
232
260
  <TouchListBody
233
- sections={sections}
261
+ sections={touchSections}
234
262
  selectedThreadId={selectedThreadId}
235
263
  selectionMode={selectionMode}
236
264
  checkedIds={checkedIds}
@@ -236,12 +236,10 @@ export const EscalationAvailable: Story = {
236
236
  * query's total, not a materialized id count, and the notice offers a way
237
237
  * back to the bounded selection.
238
238
  *
239
- * Every verb the bar carries stays available here (#114). An escalated
240
- * selection is a predicate rather than an id list, so the web-client runs
241
- * move and mark-read by paging that predicate the same way delete does —
242
- * from the bar's side nothing changes, which is the point: an escalated
243
- * selection that could only be deleted forced anyone wanting to file those
244
- * messages back to the loaded page.
239
+ * Every verb the bar carries stays available here (#114), and every one of
240
+ * them opens the wizard, which names the predicate and states its count before
241
+ * anything runs (#508). From the bar's side nothing changes, which is the
242
+ * point: an escalated selection is a selection.
245
243
  */
246
244
  export const Escalated: Story = {
247
245
  args: {
@@ -306,22 +304,6 @@ export const DeletingWithProgress: Story = {
306
304
  },
307
305
  };
308
306
 
309
- /**
310
- * After a bulk delete finishes with some batches failed: the count reflects
311
- * only what's still selected — the failures — not the original selection,
312
- * and Retry is a real button naming how many.
313
- */
314
- export const PartialFailure: Story = {
315
- args: {
316
- count: 340,
317
- notice: {
318
- tone: "danger",
319
- text: "3,072 moved to Trash. 340 couldn't be deleted.",
320
- action: { label: "Retry 340", onClick: () => undefined },
321
- },
322
- },
323
- };
324
-
325
307
  /**
326
308
  * A move over an escalated selection: same chunked run as a delete, worded for
327
309
  * the action that is running and toned as ordinary progress rather than
@@ -345,18 +327,3 @@ export const MarkingReadWithProgress: Story = {
345
327
  progress: { value: 1200, max: 3412, tone: "info" },
346
328
  },
347
329
  };
348
-
349
- /**
350
- * Partial failure of a move rather than a delete: the notice names the action
351
- * that ran, and Retry resends that same action against what is still selected.
352
- */
353
- export const PartialFailureMove: Story = {
354
- args: {
355
- count: 340,
356
- notice: {
357
- tone: "danger",
358
- text: "3,072 moved. 340 couldn't be moved.",
359
- action: { label: "Retry 340", onClick: () => undefined },
360
- },
361
- },
362
- };
@@ -45,12 +45,6 @@ export interface SelectionTopBarProps {
45
45
  * a selection spanning accounts, or a surface with no owning mailbox.
46
46
  */
47
47
  onMove?: () => void;
48
- /**
49
- * The Move verb for a selection the wizard cannot take: an escalated
50
- * predicate, which no bounded list of ids stands in for, so it keeps the
51
- * caller's own folder picker. Rendered only in `onMove`'s place.
52
- */
53
- moveSlot?: ReactNode;
54
48
  /** Opens Organize for the selection. Omitted where organize cannot be
55
49
  * scoped to one account. */
56
50
  onOrganize?: () => void;
@@ -124,7 +118,7 @@ export interface SelectionTopBarProps {
124
118
  /**
125
119
  * Toned status line below the action row, sometimes carrying an action
126
120
  * button — a cross-account move restriction, a "Select all N matching…"
127
- * escalation, a "Stop" during counting, or a partial-failure "Retry N".
121
+ * escalation, or a "Stop" during counting.
128
122
  */
129
123
  notice?: SelectionTopBarNotice;
130
124
  }
@@ -172,7 +166,6 @@ export function SelectionTopBar({
172
166
  onCancel,
173
167
  onDelete,
174
168
  onMove,
175
- moveSlot,
176
169
  onOrganize,
177
170
  onJunk,
178
171
  onMarkRead,
@@ -278,19 +271,16 @@ export function SelectionTopBar({
278
271
  aria-busy={isBusy || undefined}
279
272
  className="shrink-0"
280
273
  />
281
- {!isBusy &&
282
- (onMove ? (
283
- <Button
284
- variant="ghost"
285
- size="touch"
286
- icon={<FolderInput className="size-5" />}
287
- onClick={onMove}
288
- aria-label="Move selected messages"
289
- className="shrink-0"
290
- />
291
- ) : (
292
- moveSlot
293
- ))}
274
+ {!isBusy && onMove && (
275
+ <Button
276
+ variant="ghost"
277
+ size="touch"
278
+ icon={<FolderInput className="size-5" />}
279
+ onClick={onMove}
280
+ aria-label="Move selected messages"
281
+ className="shrink-0"
282
+ />
283
+ )}
294
284
  {!isBusy && onOrganize && (
295
285
  <Button
296
286
  variant="ghost"
@@ -257,6 +257,36 @@ describe("MatchStepBody", () => {
257
257
  assert.doesNotMatch(html, /disabled=""/);
258
258
  assert.match(html, /matching on the\s+senders instead/);
259
259
  });
260
+
261
+ it("names what an escalated predicate covers instead of offering doors", () => {
262
+ const html = renderToString(
263
+ createElement(MatchStepBody, {
264
+ ...matchProps,
265
+ mode: "escalated",
266
+ escalatedScope: 'matching "npm"',
267
+ sample: {
268
+ messages,
269
+ count: counted(1284),
270
+ label: "A sample of what matches",
271
+ },
272
+ }),
273
+ );
274
+ assert.match(html, /Every message matching &quot;npm&quot;/);
275
+ assert.match(html, /nothing to widen/);
276
+ // The three doors are gone: a predicate is the match already.
277
+ assert.doesNotMatch(html, /These 2 messages/);
278
+ assert.doesNotMatch(html, /Similar to these/);
279
+ assert.doesNotMatch(html, /Its properties/);
280
+ // The members of the match still close the screen (#477 2.3).
281
+ assert.match(html, /Booking confirmation/);
282
+ });
283
+
284
+ it("falls back to naming the list rather than naming nothing", () => {
285
+ const html = renderToString(
286
+ createElement(MatchStepBody, { ...matchProps, mode: "escalated" }),
287
+ );
288
+ assert.match(html, /Every message the list is showing/);
289
+ });
260
290
  });
261
291
 
262
292
  describe("PropertiesStepBody", () => {
@@ -439,6 +469,29 @@ describe("ReviewStepBody", () => {
439
469
  assert.match(html, /not known until the run finishes/);
440
470
  });
441
471
 
472
+ it("states an escalated predicate and the count the server gave it", () => {
473
+ const html = renderToString(
474
+ createElement(ReviewStepBody, {
475
+ ...reviewProps,
476
+ verb: "delete",
477
+ mode: "escalated",
478
+ escalatedScope: 'matching "npm"',
479
+ folder: undefined,
480
+ sample: {
481
+ messages,
482
+ count: counted(1284),
483
+ label: "A sample of what matches",
484
+ },
485
+ }),
486
+ );
487
+ assert.match(
488
+ text(html),
489
+ /Delete<\/span> all 1,284 messages matching &quot;npm&quot;/,
490
+ );
491
+ // The count is one reading of a live predicate, and the run takes another.
492
+ assert.match(html, /anything else matching by the time it runs/);
493
+ });
494
+
442
495
  it("names the rule and its stop date once the scope persists", () => {
443
496
  const html = renderToString(
444
497
  createElement(ReviewStepBody, {
@@ -486,6 +539,23 @@ describe("RunStepBody", () => {
486
539
  assert.match(html, /The rule itself is saved/);
487
540
  });
488
541
 
542
+ it("says a stopped run was never sent rather than rejected", () => {
543
+ const html = renderToString(
544
+ createElement(RunStepBody, {
545
+ ...runProps,
546
+ state: "runStopped",
547
+ scope: "once",
548
+ matched: 4,
549
+ applied: 2,
550
+ failures: messages,
551
+ }),
552
+ );
553
+ assert.match(html, /Stopped after 2/);
554
+ assert.match(html, /nothing was sent for them/);
555
+ assert.match(html, /Never sent/);
556
+ assert.doesNotMatch(html, /Server rejected/);
557
+ });
558
+
489
559
  it("ends a one-off run on its own count", () => {
490
560
  const html = renderToString(
491
561
  createElement(RunStepBody, { ...runProps, scope: "once" }),
@@ -21,7 +21,12 @@ import { Fragment, type ReactNode, useEffect, useId, useRef } from "react";
21
21
  import { cn } from "../lib/cn.js";
22
22
  import {
23
23
  backExits,
24
+ ESCALATED_MATCH_HINT,
25
+ ESCALATED_REVIEW_WARNING,
26
+ ESCALATED_SCOPE_FALLBACK,
27
+ escalatedMatchLabel,
24
28
  type MatchCount,
29
+ type MatchDoor,
25
30
  type MatchMode,
26
31
  matchDoorHint,
27
32
  matchDoorLabel,
@@ -414,7 +419,12 @@ export function FooterNav({
414
419
  export interface MatchStepProps {
415
420
  selectedCount: number;
416
421
  mode: MatchMode;
417
- onModeChange: (mode: MatchMode) => void;
422
+ /**
423
+ * Answers the door. Typed to the three doors rather than to every mode, so
424
+ * no driver can set `escalated` from a screen — the list escalates a
425
+ * selection, the wizard never does.
426
+ */
427
+ onModeChange: (mode: MatchDoor) => void;
418
428
  /**
419
429
  * Similar-mail matching cannot run right now. A runtime state, not a property
420
430
  * of the deployment: the door stays pressable and dimmed.
@@ -429,6 +439,12 @@ export interface MatchStepProps {
429
439
  /** The dimmed door was pressed and the senders were filled in instead. */
430
440
  semanticFallbackTaken?: boolean;
431
441
  onSemanticFallback: () => void;
442
+ /**
443
+ * What the escalated predicate covers, in the words the list escalated it
444
+ * with. Read only while the mode is `escalated`, where it replaces the three
445
+ * doors: the predicate is the match already.
446
+ */
447
+ escalatedScope?: string;
432
448
  sample: SelectionSampleProps;
433
449
  }
434
450
 
@@ -440,8 +456,24 @@ export function MatchStepBody({
440
456
  semanticErrorDetail,
441
457
  semanticFallbackTaken,
442
458
  onSemanticFallback,
459
+ escalatedScope,
443
460
  sample,
444
461
  }: MatchStepProps) {
462
+ if (mode === "escalated") {
463
+ return (
464
+ <>
465
+ <div className="rounded-lg border border-accent bg-accent-soft p-3">
466
+ <p className="text-sm font-medium text-fg">
467
+ {escalatedMatchLabel(escalatedScope ?? ESCALATED_SCOPE_FALLBACK)}
468
+ </p>
469
+ <p className="mt-1 text-xs text-fg-muted">{ESCALATED_MATCH_HINT}</p>
470
+ </div>
471
+ <div className="mt-4">
472
+ <SelectionSample {...sample} />
473
+ </div>
474
+ </>
475
+ );
476
+ }
445
477
  return (
446
478
  <>
447
479
  <div className="space-y-2">
@@ -821,6 +853,8 @@ export interface ReviewStepProps {
821
853
  until?: string;
822
854
  /** Present when the flow reached the naming step. */
823
855
  ruleName?: string;
856
+ /** What the escalated predicate covers, in the list's own words. */
857
+ escalatedScope?: string;
824
858
  sample: SelectionSampleProps;
825
859
  }
826
860
 
@@ -834,10 +868,21 @@ export function ReviewStepBody({
834
868
  scope,
835
869
  until,
836
870
  ruleName,
871
+ escalatedScope,
837
872
  sample,
838
873
  }: ReviewStepProps) {
839
874
  const { label } = verbCopy(verb);
840
- const description = { mode, selectedCount, clauses, matchOperator };
875
+ const description = {
876
+ mode,
877
+ selectedCount,
878
+ clauses,
879
+ matchOperator,
880
+ escalatedScope,
881
+ // A count still moving is not a number to commit against, and the footer
882
+ // under the review's Continue already says so.
883
+ escalatedCount:
884
+ sample.count.status === "ready" ? sample.count.count : undefined,
885
+ };
841
886
  const widened = mode !== "selected";
842
887
  const persists = scope === "standing" || scope === "until";
843
888
 
@@ -860,7 +905,9 @@ export function ReviewStepBody({
860
905
  {widened && (
861
906
  <p className="mt-2 flex items-start gap-1.5 text-xs text-warning">
862
907
  <AlertTriangle className="mt-px size-3.5 shrink-0" />
863
- This covers messages not shown in the list.
908
+ {mode === "escalated"
909
+ ? ESCALATED_REVIEW_WARNING
910
+ : "This covers messages not shown in the list."}
864
911
  </p>
865
912
  )}
866
913
  </div>
@@ -950,6 +997,9 @@ export function RunStepBody(props: RunStepProps) {
950
997
  const { matched, applied, failures } = props;
951
998
  const outcome = runOutcomeOf(props);
952
999
  const copy = runCopy(outcome);
1000
+ // A stopped run never sent these, so nothing rejected them.
1001
+ const failureBadge =
1002
+ props.state === "runStopped" ? "Never sent" : "Server rejected";
953
1003
 
954
1004
  return (
955
1005
  <div className="space-y-4 pt-2">
@@ -980,7 +1030,7 @@ export function RunStepBody(props: RunStepProps) {
980
1030
  {message.subject}
981
1031
  </p>
982
1032
  <Badge className="mt-1" tone="warning">
983
- Server rejected
1033
+ {failureBadge}
984
1034
  </Badge>
985
1035
  </li>
986
1036
  ))}
@@ -1148,10 +1198,17 @@ export function SelectionWizard(props: SelectionWizardProps) {
1148
1198
  return runCopy(runOutcomeOf(stepProps(props.run, step))).screenTitle;
1149
1199
  };
1150
1200
 
1201
+ // An escalated predicate is offered no doors, so the screen states what it
1202
+ // covers rather than asking a question with one answer.
1203
+ const subtitle =
1204
+ step === "match" && props.match?.mode === "escalated"
1205
+ ? "What this applies to"
1206
+ : screen.subtitle;
1207
+
1151
1208
  return (
1152
1209
  <WizardScreen
1153
1210
  title={title()}
1154
- subtitle={screen.subtitle}
1211
+ subtitle={subtitle}
1155
1212
  steps={steps}
1156
1213
  step={step}
1157
1214
  onBack={onBack}
package/src/index.ts CHANGED
@@ -669,8 +669,13 @@ export {
669
669
  clauseWords,
670
670
  crossAccountDestinationReason,
671
671
  crossAccountRuleReason,
672
+ ESCALATED_MATCH_HINT,
673
+ ESCALATED_REVIEW_WARNING,
674
+ ESCALATED_SCOPE_FALLBACK,
675
+ escalatedMatchLabel,
672
676
  type MatchCount,
673
677
  type MatchDescription,
678
+ type MatchDoor,
674
679
  type MatchMode,
675
680
  matchDoorHint,
676
681
  matchDoorLabel,
@@ -9,6 +9,9 @@ import {
9
9
  backExits,
10
10
  clauseSentence,
11
11
  clauseWords,
12
+ ESCALATED_MATCH_HINT,
13
+ ESCALATED_REVIEW_WARNING,
14
+ escalatedMatchLabel,
12
15
  type MatchCount,
13
16
  type MatchMode,
14
17
  matchDoorHint,
@@ -30,7 +33,7 @@ import {
30
33
  } from "./wizard-steps.js";
31
34
 
32
35
  const VERBS: Verb[] = ["delete", "move", "junk", "markRead", "organize"];
33
- const MODES: MatchMode[] = ["selected", "similar", "properties"];
36
+ const MODES: MatchMode[] = ["selected", "similar", "properties", "escalated"];
34
37
  const SCOPES: (RuleScope | undefined)[] = [
35
38
  undefined,
36
39
  "once",
@@ -475,6 +478,7 @@ describe("runCopy", () => {
475
478
  "backApplyFailed",
476
479
  "backApplyStartFailed",
477
480
  "filterSaved",
481
+ "runStopped",
478
482
  "commitFailed",
479
483
  ];
480
484
  for (const state of states) {
@@ -499,6 +503,23 @@ describe("runCopy", () => {
499
503
  assert.doesNotMatch(once.detail, /rule/);
500
504
  });
501
505
 
506
+ it("separates a run that stopped from a pass the server rejected", () => {
507
+ // The bulk endpoints accept every id in a call that returns, so the only
508
+ // failure the chunked runner can observe is a call that threw — and
509
+ // everything after it was never sent. Saying the mail server rejected
510
+ // those states a cause that did not happen.
511
+ const stopped = outcome("runStopped", "once");
512
+ assert.equal(stopped.title, "Stopped after 10");
513
+ assert.match(stopped.detail, /nothing was sent for them/);
514
+ assert.doesNotMatch(stopped.detail, /rejected/);
515
+ assert.equal(stopped.retryLabel, "Retry 2");
516
+ assert.equal(stopped.showProgress, true);
517
+
518
+ // The back-apply pass is run by the server, which reports what it could
519
+ // not apply — a rejection, and worded as one.
520
+ assert.match(outcome("backApplyFailed", "once").detail, /rejected/);
521
+ });
522
+
502
523
  it("ends a one-off run on its own count and a saved rule on the rule", () => {
503
524
  assert.equal(outcome("backApplyComplete", "once").title, "Moved 10");
504
525
  assert.equal(
@@ -585,6 +606,15 @@ describe("sample and door vocabulary", () => {
585
606
  assert.match(sampleEmptyCopy("notIndexed"), /isn't indexed yet/);
586
607
  });
587
608
 
609
+ it("states what an escalated predicate covers instead of a door", () => {
610
+ assert.equal(
611
+ escalatedMatchLabel('matching "npm"'),
612
+ 'Every message matching "npm"',
613
+ );
614
+ assert.match(ESCALATED_MATCH_HINT, /nothing to widen/);
615
+ assert.match(ESCALATED_REVIEW_WARNING, /by the time it runs/);
616
+ });
617
+
588
618
  it("names each door and what it does", () => {
589
619
  assert.equal(matchDoorLabel("selected", 3), "These 3 messages");
590
620
  assert.equal(matchDoorLabel("similar", 3), "Similar to these 3");
@@ -661,6 +691,55 @@ describe("the match as words", () => {
661
691
  );
662
692
  });
663
693
 
694
+ it("summarises an escalated predicate by what it covers", () => {
695
+ assert.equal(
696
+ matchSummary({
697
+ mode: "escalated",
698
+ selectedCount: 4,
699
+ clauses,
700
+ matchOperator: "all",
701
+ escalatedScope: 'matching "npm"',
702
+ }),
703
+ 'Every message matching "npm"',
704
+ );
705
+ assert.equal(
706
+ matchSummary({
707
+ mode: "escalated",
708
+ selectedCount: 4,
709
+ clauses,
710
+ matchOperator: "all",
711
+ }),
712
+ "Every message the list is showing",
713
+ );
714
+ });
715
+
716
+ it("phrases an escalated predicate with the count the server gave it", () => {
717
+ assert.equal(
718
+ matchPhrase({
719
+ mode: "escalated",
720
+ selectedCount: 4,
721
+ clauses,
722
+ matchOperator: "all",
723
+ escalatedScope: 'matching "npm"',
724
+ escalatedCount: 3412,
725
+ }),
726
+ 'all 3,412 messages matching "npm"',
727
+ );
728
+ });
729
+
730
+ it("claims no number while the count is still being taken", () => {
731
+ assert.equal(
732
+ matchPhrase({
733
+ mode: "escalated",
734
+ selectedCount: 4,
735
+ clauses,
736
+ matchOperator: "all",
737
+ escalatedScope: 'matching "npm"',
738
+ }),
739
+ 'every message matching "npm"',
740
+ );
741
+ });
742
+
664
743
  it("phrases each door for the review sentence", () => {
665
744
  assert.equal(
666
745
  matchPhrase({
@@ -70,12 +70,21 @@ const VERB_COPY: Record<Verb, VerbCopy> = {
70
70
  export const verbCopy = (verb: Verb): VerbCopy => VERB_COPY[verb];
71
71
 
72
72
  /**
73
- * What the action is applied to. The two widened doors are the shipped
74
- * `RuleMatchMode` values; `selected` is not one of them and is not a match mode
75
- * at all — it is the bounded list of ticked message ids, which no predicate
76
- * stands in for.
73
+ * The three choices the match step offers a ticked selection. The two widened
74
+ * doors are the shipped `RuleMatchMode` values; `selected` is not one of them
75
+ * and is not a match mode at all — it is the bounded list of ticked message
76
+ * ids, which no predicate stands in for.
77
77
  */
78
- export type MatchMode = "selected" | RuleMatchMode;
78
+ export type MatchDoor = "selected" | RuleMatchMode;
79
+
80
+ /**
81
+ * What the action is applied to. Beside the three doors sits `escalated`: the
82
+ * selection the list escalated to a predicate, every message matching the query
83
+ * it is showing rather than the rows on screen. It is offered no doors, because
84
+ * the predicate is already the match and there is nothing left to widen; the
85
+ * step names what it covers instead.
86
+ */
87
+ export type MatchMode = MatchDoor | "escalated";
79
88
 
80
89
  export type StepId =
81
90
  | "match"
@@ -279,6 +288,12 @@ export const stepBlockedReason = (
279
288
  * Where the run step lands. Three of these never reach a job: a filter saved
280
289
  * with nothing to back-apply, a back-apply whose request never started, and a
281
290
  * create that failed outright.
291
+ *
292
+ * `backApplyFailed` and `runStopped` are two different endings and must not be
293
+ * confused. A back-apply that failed was run by the server, which reports how
294
+ * many messages it could not apply the rule to. A run that stopped hit an
295
+ * infrastructure failure part-way: the batches it never reached were never
296
+ * sent, so nothing rejected them and nothing happened to them.
282
297
  */
283
298
  export type RunState =
284
299
  | "saving"
@@ -287,6 +302,7 @@ export type RunState =
287
302
  | "backApplyFailed"
288
303
  | "backApplyStartFailed"
289
304
  | "filterSaved"
305
+ | "runStopped"
290
306
  | "commitFailed";
291
307
 
292
308
  /**
@@ -358,7 +374,8 @@ export const runCopy = ({
358
374
  showProgress:
359
375
  state === "backApplyRunning" ||
360
376
  state === "backApplyComplete" ||
361
- state === "backApplyFailed",
377
+ state === "backApplyFailed" ||
378
+ state === "runStopped",
362
379
  failureListLabel: `Not ${done}`,
363
380
  };
364
381
 
@@ -399,7 +416,7 @@ export const runCopy = ({
399
416
  title: standing
400
417
  ? "Rule saved — some mail stayed put"
401
418
  : `Not everything was ${done}`,
402
- detail: `${applied} of ${matched} ${done} · ${failed} rejected by the mail server.${
419
+ detail: `${applied} of ${matched} ${done} · the mail server rejected ${failed}.${
403
420
  standing
404
421
  ? " The rule itself is saved and keeps working on new mail."
405
422
  : ""
@@ -409,6 +426,16 @@ export const runCopy = ({
409
426
  retryLabel: `Retry ${failed}`,
410
427
  };
411
428
  }
429
+ if (state === "runStopped") {
430
+ return {
431
+ ...shared,
432
+ title: `Stopped after ${applied}`,
433
+ detail: `${applied} of ${matched} ${done}. The run stopped before it reached the rest, so nothing was sent for them and nothing has happened to them.`,
434
+ tone: "warning",
435
+ dismissLabel: "Close",
436
+ retryLabel: `Retry ${failed}`,
437
+ };
438
+ }
412
439
  if (state === "backApplyStartFailed") {
413
440
  return {
414
441
  ...shared,
@@ -454,7 +481,7 @@ export const clauseSentence = (
454
481
 
455
482
  /** What a match door is called, with the ticked count the widen anchors on. */
456
483
  export const matchDoorLabel = (
457
- mode: MatchMode,
484
+ mode: MatchDoor,
458
485
  selectedCount: number,
459
486
  ): string => {
460
487
  if (mode === "selected") return `These ${selectedCount} messages`;
@@ -463,16 +490,48 @@ export const matchDoorLabel = (
463
490
  };
464
491
 
465
492
  /** One line saying what a match door actually does, so the choice is never a guess. */
466
- export const matchDoorHint = (mode: MatchMode): string =>
493
+ export const matchDoorHint = (mode: MatchDoor): string =>
467
494
  mode === "selected"
468
495
  ? "Only the messages ticked in the list."
469
496
  : matchModeHint(mode);
470
497
 
498
+ /**
499
+ * What an escalated predicate covers, in the words the list already used to
500
+ * escalate it — `matching "npm"`. Named where the doors would be, because a
501
+ * predicate that reaches past the loaded rows has nothing left to widen.
502
+ */
503
+ export const escalatedMatchLabel = (scope: string): string =>
504
+ `Every message ${scope}`;
505
+
506
+ /** The fallback when the list hands over no words for its predicate. */
507
+ export const ESCALATED_SCOPE_FALLBACK = "the list is showing";
508
+
509
+ /** Why the escalated match step states one thing instead of offering three. */
510
+ export const ESCALATED_MATCH_HINT =
511
+ "This reaches every match on the mail server, including the ones the list has not loaded. The search is already the match, so there is nothing to widen.";
512
+
513
+ /**
514
+ * What the count on an escalated review does and does not promise (#109). The
515
+ * predicate is resolved once for the count and again for the run, so the run
516
+ * covers whatever matches at the moment it runs — which can be more than the
517
+ * number beside it.
518
+ */
519
+ export const ESCALATED_REVIEW_WARNING =
520
+ "This covers messages not shown in the list, and anything else matching by the time it runs.";
521
+
471
522
  export interface MatchDescription {
472
523
  mode: MatchMode;
473
524
  selectedCount: number;
474
525
  clauses: readonly RuleClause[];
475
526
  matchOperator: MatchOperator;
527
+ /** What an escalated predicate covers, in the list's own words. */
528
+ escalatedScope?: string;
529
+ /**
530
+ * The server's count of that predicate. Stated with the match rather than
531
+ * left to the sample's footer: an escalated selection is the one match whose
532
+ * size is the whole reason to look before it runs.
533
+ */
534
+ escalatedCount?: number;
476
535
  }
477
536
 
478
537
  /** The match on the review screen's labelled list — short enough for one row. */
@@ -481,10 +540,15 @@ export const matchSummary = ({
481
540
  selectedCount,
482
541
  clauses,
483
542
  matchOperator,
484
- }: MatchDescription): string =>
485
- mode === "properties"
543
+ escalatedScope,
544
+ }: MatchDescription): string => {
545
+ if (mode === "escalated") {
546
+ return escalatedMatchLabel(escalatedScope ?? ESCALATED_SCOPE_FALLBACK);
547
+ }
548
+ return mode === "properties"
486
549
  ? clauseSentence(clauses, matchOperator)
487
550
  : matchDoorLabel(mode, selectedCount);
551
+ };
488
552
 
489
553
  /** The match inside the review screen's one sentence, in the object position. */
490
554
  export const matchPhrase = ({
@@ -492,8 +556,16 @@ export const matchPhrase = ({
492
556
  selectedCount,
493
557
  clauses,
494
558
  matchOperator,
559
+ escalatedScope,
560
+ escalatedCount,
495
561
  }: MatchDescription): string => {
496
562
  if (mode === "selected") return `${selectedCount} messages`;
563
+ if (mode === "escalated") {
564
+ const scope = escalatedScope ?? ESCALATED_SCOPE_FALLBACK;
565
+ return escalatedCount === undefined
566
+ ? `every message ${scope}`
567
+ : `all ${escalatedCount.toLocaleString()} messages ${scope}`;
568
+ }
497
569
  if (mode === "similar") {
498
570
  return `mail ${widenChipLabel({ anchorCount: selectedCount }).toLowerCase()}`;
499
571
  }