@remit/web-client 0.0.55 → 0.0.57

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.55",
3
+ "version": "0.0.57",
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": {
@@ -70,13 +70,14 @@ describe("MessageList escalated actions", () => {
70
70
  });
71
71
 
72
72
  it("keeps mark-read and the move slot on an escalated selection", () => {
73
+ // The mobile sheet always carries the mark-read verb (it hides it itself
74
+ // while counting or busy); an escalated selection must never lose it.
75
+ assert.match(source, /onMarkRead=\{handleMarkAsRead\}/);
76
+ // The move slot is offered for a bounded selection or an escalated one —
77
+ // #114's rule that an escalated selection is never delete-only.
73
78
  assert.match(
74
79
  source,
75
- /onMarkRead=\{\s*escalation\.phase\.kind === "counting" \? undefined : handleMarkAsRead/,
76
- );
77
- assert.match(
78
- source,
79
- /moveSlot=\{\s*escalation\.phase\.kind !== "counting"/,
80
+ /onMoveMessages \|\| escalation\.phase\.kind === "escalated"/,
80
81
  );
81
82
  });
82
83
 
@@ -3,15 +3,17 @@ import {
3
3
  Banner,
4
4
  type Density,
5
5
  MessageListPane,
6
- SelectionTopBar,
6
+ SELECTION_SHEET_TEASER_HEIGHT,
7
+ SelectionSheet,
7
8
  } from "@remit/ui";
8
9
  import { useBlocker, useNavigate } from "@tanstack/react-router";
9
10
  import { useVirtualizer } from "@tanstack/react-virtual";
10
- import { Search, Sparkles } from "lucide-react";
11
+ import { Search } from "lucide-react";
11
12
  import type { RefObject } from "react";
12
13
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13
14
  import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
14
15
  import { formatErrorMessage } from "@/components/ui/ErrorState";
16
+ import { useJunkMailbox } from "@/hooks/useArchiveMailbox";
15
17
  import {
16
18
  type EscalatedAction,
17
19
  type EscalationSearchQuery,
@@ -45,8 +47,13 @@ import {
45
47
  deriveIsMultiSelectMode,
46
48
  shouldExitSelectionOnNavigate,
47
49
  } from "@/lib/selection-mode";
50
+ import {
51
+ resolveSelectionSheetMode,
52
+ shouldShowSelectionSheet,
53
+ } from "@/lib/selection-sheet-mode";
48
54
  import { cn } from "@/lib/utils";
49
55
  import { MoveToTrigger } from "./MoveToTrigger";
56
+ import { MobileOrganizeFlow } from "./organize/MobileOrganizeFlow";
50
57
  import { OrganizeDialog } from "./organize/OrganizeDialog";
51
58
  import { SelectionToolbar } from "./SelectionToolbar";
52
59
  import { SwipeableMessageRow } from "./SwipeableMessageRow";
@@ -215,6 +222,12 @@ export const MessageList = ({
215
222
  const navigate = useNavigate();
216
223
  const isDesktop = useIsDesktop();
217
224
  const [organizeOpen, setOrganizeOpen] = useState(false);
225
+ // Mobile only: which selection-sheet entry opened the guided organize flow
226
+ // (`null` when closed). Desktop uses `organizeOpen` + `OrganizeDialog`
227
+ // instead — a sheet is a touch pattern (issue #211).
228
+ const [mobileOrganizeEntry, setMobileOrganizeEntry] = useState<
229
+ "select-similar" | "something-else" | null
230
+ >(null);
218
231
  const isSearching = !!searchQuery?.trim();
219
232
 
220
233
  // Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
@@ -244,6 +257,10 @@ export const MessageList = ({
244
257
  // Swipe-to-read toggle hook
245
258
  const { toggleReadFor } = useToggleReadFor({ mailboxId });
246
259
 
260
+ // The Junk quick action moves the selection to the account's appointed Junk
261
+ // mailbox — the message-flags API has no `$Junk` field, so "junk" is a move.
262
+ const { junkMailboxId } = useJunkMailbox(accountId);
263
+
247
264
  // Selection state
248
265
  const {
249
266
  selectedIds,
@@ -265,6 +282,13 @@ export const MessageList = ({
265
282
  // it back to the count, and across that render the two disagree.
266
283
  const isMultiSelectMode = deriveIsMultiSelectMode(selectedCount, isDesktop);
267
284
 
285
+ // When the selection empties — cancel, a completed action, a mailbox switch —
286
+ // the guided organize entry must reset too, so a later re-selection doesn't
287
+ // reopen the flow on a stale entry (issue #211).
288
+ useEffect(() => {
289
+ if (selectedCount === 0) setMobileOrganizeEntry(null);
290
+ }, [selectedCount]);
291
+
268
292
  // Pending delete, awaiting confirmation. `null` means the dialog is closed.
269
293
  // `source: "ids"` snapshots the concrete ids at request time so a selection
270
294
  // change behind the dialog can't retarget the delete — every keyboard/desktop
@@ -742,6 +766,14 @@ export const MessageList = ({
742
766
  ],
743
767
  );
744
768
 
769
+ // Junk quick action (mobile sheet): move the selection to the appointed Junk
770
+ // mailbox. Reuses the same bounded/escalated move path as any other move, so
771
+ // the chunked run and the cross-account guard apply unchanged.
772
+ const handleJunk = useCallback(() => {
773
+ if (!junkMailboxId) return;
774
+ handleMoveSelected(junkMailboxId);
775
+ }, [junkMailboxId, handleMoveSelected]);
776
+
745
777
  // Cross-account guard: every selected thread row must belong to the
746
778
  // same account as the current mailbox. The list is already scoped to
747
779
  // one mailbox so in practice this is always single-account, but we
@@ -1174,61 +1206,105 @@ export const MessageList = ({
1174
1206
  ? { tone: "warning" as const, text: moveDisabledHint }
1175
1207
  : undefined;
1176
1208
 
1177
- // Mobile multi-select bar replaces the pane header during selection mode.
1178
- const mobileSelectionBar =
1179
- isMultiSelectMode && !isDesktop ? (
1180
- <SelectionTopBar
1181
- count={mobileCount}
1182
- onCancel={handleMobileCancel}
1183
- onDelete={handleMobileDelete}
1184
- onMarkRead={
1185
- escalation.phase.kind === "counting" ? undefined : handleMarkAsRead
1186
- }
1187
- isBusy={mobileIsBusy}
1188
- isCounting={escalation.phase.kind === "counting"}
1189
- statusLabel={mobileStatusLabel}
1190
- selectAll={mobileSelectAll}
1191
- progress={
1192
- escalation.runningAction && escalation.progress
1193
- ? {
1194
- value: escalation.progress.done,
1195
- max: escalation.progress.total,
1196
- tone: bulkActionProgressTone(escalation.runningAction.kind),
1197
- }
1198
- : undefined
1199
- }
1200
- notice={mobileNotice}
1201
- moveSlot={
1202
- escalation.phase.kind !== "counting" &&
1203
- !escalation.isRunning &&
1204
- (onMoveMessages || escalation.phase.kind === "escalated") &&
1205
- accountId &&
1206
- mailboxId ? (
1207
- <>
1208
- {escalation.phase.kind === "idle" && !moveDisabledHint && (
1209
- <button
1210
- type="button"
1211
- onClick={() => setOrganizeOpen(true)}
1212
- className="min-h-11 min-w-11 inline-flex shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-raised"
1213
- aria-label="Organize similar messages"
1214
- >
1215
- <Sparkles className="size-4" />
1216
- </button>
1217
- )}
1218
- <MoveToTrigger
1219
- accountId={accountId}
1220
- currentMailboxId={mailboxId}
1221
- onMove={isDeleting || isMoving ? () => {} : handleMoveSelected}
1222
- disabledHint={moveDisabledHint}
1223
- label="Move selected messages"
1224
- />
1225
- </>
1226
- ) : undefined
1227
- }
1209
+ // Mobile: which content the peeking sheet routes to, from the escalation
1210
+ // machinery. `running` (a chunked delete/move/mark-read) and `counting` (a
1211
+ // predicate still paging) replace the idle quick actions with status and
1212
+ // progress; `escalated` keeps the verbs over the whole predicate.
1213
+ const mobileMode = resolveSelectionSheetMode({
1214
+ isRunning: escalation.isRunning,
1215
+ isCounting: escalation.phase.kind === "counting",
1216
+ isEscalated: escalation.phase.kind === "escalated",
1217
+ });
1218
+
1219
+ // The sheet is the sole mobile selection surface. It rises at 2+ selected
1220
+ // (the prototype threshold) or in any non-idle escalation state — a single
1221
+ // selected row enters selection mode without raising it, matching today.
1222
+ const showMobileSheet =
1223
+ !isDesktop &&
1224
+ isMultiSelectMode &&
1225
+ shouldShowSelectionSheet(
1226
+ mobileCount,
1227
+ mobileMode,
1228
+ mobileNotice !== undefined,
1229
+ );
1230
+
1231
+ const mobileMoveSlot =
1232
+ (onMoveMessages || escalation.phase.kind === "escalated") &&
1233
+ accountId &&
1234
+ mailboxId ? (
1235
+ <MoveToTrigger
1236
+ accountId={accountId}
1237
+ currentMailboxId={mailboxId}
1238
+ onMove={isDeleting || isMoving ? () => {} : handleMoveSelected}
1239
+ disabledHint={moveDisabledHint}
1240
+ label="Move selected messages"
1228
1241
  />
1229
1242
  ) : undefined;
1230
1243
 
1231
- const activeSelectionBar = desktopSelectionBar ?? mobileSelectionBar;
1244
+ // The guided organize flow (issue #211) overlays the same positioned pane
1245
+ // ancestor as the sheet, so it is rendered from the sheet slot. The sheet
1246
+ // stays mounted underneath as the flow's dimmed backdrop.
1247
+ const mobileOrganizeFlow =
1248
+ mobileOrganizeEntry && accountId && !isDesktop && selectedCount > 0 ? (
1249
+ <MobileOrganizeFlow
1250
+ entry={mobileOrganizeEntry}
1251
+ accountId={accountId}
1252
+ mailboxId={mailboxId}
1253
+ selectedMessageIds={Array.from(selectedIds)}
1254
+ junkMailboxId={junkMailboxId}
1255
+ onClose={() => {
1256
+ setMobileOrganizeEntry(null);
1257
+ exitSelection();
1258
+ }}
1259
+ />
1260
+ ) : undefined;
1261
+
1262
+ const mobileSelectionSheet = showMobileSheet ? (
1263
+ <SelectionSheet
1264
+ count={mobileCount}
1265
+ mode={mobileMode}
1266
+ onCancel={handleMobileCancel}
1267
+ onDelete={handleMobileDelete}
1268
+ onJunk={
1269
+ junkMailboxId && junkMailboxId !== mailboxId && !moveDisabledHint
1270
+ ? handleJunk
1271
+ : undefined
1272
+ }
1273
+ onMarkRead={handleMarkAsRead}
1274
+ onSelectSimilar={
1275
+ accountId ? () => setMobileOrganizeEntry("select-similar") : undefined
1276
+ }
1277
+ onSomethingElse={
1278
+ accountId ? () => setMobileOrganizeEntry("something-else") : undefined
1279
+ }
1280
+ moveSlot={mobileMoveSlot}
1281
+ isBusy={mobileIsBusy}
1282
+ selectAll={mobileSelectAll}
1283
+ statusLabel={mobileStatusLabel}
1284
+ progress={
1285
+ escalation.runningAction && escalation.progress
1286
+ ? {
1287
+ value: escalation.progress.done,
1288
+ max: escalation.progress.total,
1289
+ tone: bulkActionProgressTone(escalation.runningAction.kind),
1290
+ }
1291
+ : undefined
1292
+ }
1293
+ notice={mobileNotice}
1294
+ />
1295
+ ) : undefined;
1296
+
1297
+ // Both mobile overlays share the pane's positioned ancestor via the sheet
1298
+ // slot: the peeking sheet, and the guided organize flow above it.
1299
+ const mobileSelectionSurface =
1300
+ mobileSelectionSheet || mobileOrganizeFlow ? (
1301
+ <>
1302
+ {mobileSelectionSheet}
1303
+ {mobileOrganizeFlow}
1304
+ </>
1305
+ ) : undefined;
1306
+
1307
+ const activeSelectionBar = desktopSelectionBar;
1232
1308
 
1233
1309
  // Roving tabindex: exactly one row is in the tab order, so Tab moves focus
1234
1310
  // into the list at the cursor and Shift+Tab moves back out to the side panel
@@ -1257,6 +1333,13 @@ export const MessageList = ({
1257
1333
  aria-multiselectable
1258
1334
  aria-label={listTitle}
1259
1335
  className="flex-1 overflow-y-auto"
1336
+ // Pad the list so its last rows clear the peeking teaser (~56px)
1337
+ // rather than hiding behind it.
1338
+ style={
1339
+ showMobileSheet
1340
+ ? { paddingBottom: SELECTION_SHEET_TEASER_HEIGHT }
1341
+ : undefined
1342
+ }
1260
1343
  >
1261
1344
  {/* Virtualizer scaffolding — presentational so the listbox sees the
1262
1345
  rows as its options rather than these positioning wrappers. */}
@@ -1364,6 +1447,7 @@ export const MessageList = ({
1364
1447
  isDesktop={isDesktop}
1365
1448
  hideHeader={hideHeader}
1366
1449
  selectionBar={activeSelectionBar}
1450
+ selectionSheet={mobileSelectionSurface}
1367
1451
  listBody={listState === "ready" ? virtualBody : undefined}
1368
1452
  />
1369
1453
  <ConfirmDialog
@@ -0,0 +1,45 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import React, { createElement } from "react";
5
+ import { renderToString } from "react-dom/server";
6
+ import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
7
+ import type { OrganizeEntry } from "@/lib/organize/mobile-organize-flow";
8
+ import { MobileOrganizeFlow } from "./MobileOrganizeFlow";
9
+
10
+ // The node test loader transpiles remit-ui's `.tsx` with the classic JSX
11
+ // runtime, which references a global `React`. Vite uses the automatic runtime,
12
+ // so this shim only exists for the SSR test harness.
13
+ (globalThis as { React?: typeof React }).React = React;
14
+
15
+ const render = (entry: OrganizeEntry) =>
16
+ renderToString(
17
+ createElement(
18
+ QueryClientProvider,
19
+ { client: new QueryClient() },
20
+ createElement(
21
+ ErrorBannerProvider,
22
+ null,
23
+ createElement(MobileOrganizeFlow, {
24
+ entry,
25
+ accountId: "acc-1",
26
+ mailboxId: "mbx-inbox",
27
+ selectedMessageIds: ["msg-1", "msg-2"],
28
+ onClose: () => undefined,
29
+ }),
30
+ ),
31
+ ) as never,
32
+ );
33
+
34
+ describe("MobileOrganizeFlow", () => {
35
+ it("select-similar opens on the widening state before the preview resolves", () => {
36
+ const html = render("select-similar");
37
+ assert.match(html, /Finding similar messages/);
38
+ });
39
+
40
+ it("something-else opens on the shortcuts + plain-language input", () => {
41
+ const html = render("something-else");
42
+ assert.match(html, /What should Remit do\?/);
43
+ assert.match(html, /Tell Remit what to do/);
44
+ });
45
+ });
@@ -0,0 +1,138 @@
1
+ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import { BottomSheet, Button } from "@remit/ui";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { Loader2 } from "lucide-react";
5
+ import { useEffect, useMemo, useState } from "react";
6
+ import { useOrganizePreview } from "@/hooks/useOrganizePreview";
7
+ import { getMailboxDisplayName } from "@/lib/folder-roles";
8
+ import { buildMoveTargets } from "@/lib/move-targets";
9
+ import {
10
+ type OrganizeEntry,
11
+ type OrganizeSeed,
12
+ type PreviewStatus,
13
+ resolveOrganizeStage,
14
+ } from "@/lib/organize/mobile-organize-flow";
15
+ import { OrganizePanel } from "./OrganizePanel";
16
+ import { SomethingElsePanel } from "./SomethingElsePanel";
17
+
18
+ interface MobileOrganizeFlowProps {
19
+ entry: OrganizeEntry;
20
+ accountId: string;
21
+ mailboxId: string;
22
+ selectedMessageIds: string[];
23
+ junkMailboxId?: string;
24
+ /** Close the flow and return to the list — dismiss, "Not now", and Done all use it. */
25
+ onClose: () => void;
26
+ }
27
+
28
+ const previewStatusOf = (
29
+ isError: boolean,
30
+ isPending: boolean,
31
+ matchedCount: number | undefined,
32
+ ): PreviewStatus => {
33
+ if (isError) return "error";
34
+ if (isPending) return "pending";
35
+ return matchedCount !== undefined ? "success" : "idle";
36
+ };
37
+
38
+ /**
39
+ * The guided select-similar → organize flow, the mobile home for organizing
40
+ * (issue #211). Entered from the selection sheet, it widens the selection once
41
+ * with the read-only matcher (POST /organize/preview), shows a brief widening
42
+ * state, and renders the organize sentence inside a bottom sheet on that
43
+ * widened set — the same {@link OrganizePanel} the desktop dialog uses, so the
44
+ * two never drift. "Something else" collects a folder/scope seed first; a widen
45
+ * that matches nothing falls back to organizing the selection. Desktop keeps
46
+ * its `OrganizeDialog` — this is the touch surface only.
47
+ */
48
+ export function MobileOrganizeFlow({
49
+ entry,
50
+ accountId,
51
+ mailboxId,
52
+ selectedMessageIds,
53
+ junkMailboxId,
54
+ onClose,
55
+ }: MobileOrganizeFlowProps) {
56
+ const anchorMessageId = selectedMessageIds[0];
57
+ const [seed, setSeed] = useState<OrganizeSeed | undefined>();
58
+
59
+ const { preview, matchedCount, isPending, isError, error } =
60
+ useOrganizePreview(accountId);
61
+
62
+ useEffect(() => {
63
+ if (!anchorMessageId) return;
64
+ preview({ anchorMessageId, matchOperator: "And", literalClauses: [] });
65
+ }, [anchorMessageId, preview]);
66
+
67
+ const { data: mailboxesData } = useQuery({
68
+ ...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
69
+ staleTime: Number.POSITIVE_INFINITY,
70
+ });
71
+
72
+ const folderOptions = useMemo(
73
+ () =>
74
+ buildMoveTargets(mailboxesData?.items ?? []).map((mailbox) => ({
75
+ id: mailbox.mailboxId,
76
+ label: getMailboxDisplayName(mailbox.fullPath),
77
+ })),
78
+ [mailboxesData?.items],
79
+ );
80
+
81
+ const stage = resolveOrganizeStage({
82
+ entry,
83
+ hasSeed: seed !== undefined,
84
+ previewStatus: previewStatusOf(isError, isPending, matchedCount),
85
+ matchedCount,
86
+ });
87
+
88
+ return (
89
+ <BottomSheet open onClose={onClose} dismissLabel="Dismiss organize">
90
+ {stage.kind === "something-else" && (
91
+ <SomethingElsePanel
92
+ folderOptions={folderOptions}
93
+ junkMailboxId={junkMailboxId}
94
+ onSeed={setSeed}
95
+ />
96
+ )}
97
+
98
+ {stage.kind === "widening" && <WideningState />}
99
+
100
+ {stage.kind === "error" && (
101
+ <div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
102
+ <p className="text-sm font-medium text-danger">
103
+ Couldn't find similar messages
104
+ </p>
105
+ <p className="max-w-xs text-xs text-fg-muted">
106
+ {error instanceof Error ? error.message : "Please try again."}
107
+ </p>
108
+ <Button variant="ghost" onClick={onClose} className="mt-2">
109
+ Close
110
+ </Button>
111
+ </div>
112
+ )}
113
+
114
+ {stage.kind === "organize" && (
115
+ <OrganizePanel
116
+ accountId={accountId}
117
+ mailboxId={mailboxId}
118
+ selectedMessageIds={selectedMessageIds}
119
+ anchorMessageId={anchorMessageId}
120
+ matchedCount={stage.matchedCount}
121
+ initialScope={seed?.scope}
122
+ seedMailboxId={seed?.moveMailboxId}
123
+ fallback={stage.fallback}
124
+ onClose={onClose}
125
+ />
126
+ )}
127
+ </BottomSheet>
128
+ );
129
+ }
130
+
131
+ function WideningState() {
132
+ return (
133
+ <div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
134
+ <Loader2 className="size-8 animate-spin text-accent-2" />
135
+ <p className="text-sm font-medium text-fg">Finding similar messages…</p>
136
+ </div>
137
+ );
138
+ }
@@ -14,7 +14,7 @@ import { OrganizePanel } from "./OrganizePanel";
14
14
  // so this shim only exists for the SSR test harness.
15
15
  (globalThis as { React?: typeof React }).React = React;
16
16
 
17
- const render = () =>
17
+ const render = (overrides: Partial<Parameters<typeof OrganizePanel>[0]> = {}) =>
18
18
  renderToString(
19
19
  createElement(
20
20
  QueryClientProvider,
@@ -29,6 +29,7 @@ const render = () =>
29
29
  anchorMessageId: "msg-1",
30
30
  matchedCount: 47,
31
31
  onClose: () => undefined,
32
+ ...overrides,
32
33
  }),
33
34
  ),
34
35
  ) as never,
@@ -54,6 +55,18 @@ describe("OrganizePanel", () => {
54
55
  assert.match(html, /These and new mail like this/);
55
56
  assert.match(html, /Until a date/);
56
57
  });
58
+
59
+ it("says it is organizing just the selection when the widen matched nothing (#211 — no dead end)", () => {
60
+ const html = render({ matchedCount: 0, fallback: true });
61
+ assert.match(html, /No similar messages found/);
62
+ assert.match(html, /organizing just your 2 selected/);
63
+ });
64
+
65
+ it("pre-selects the seeded scope (a 'Something else' shortcut seeds the sentence)", () => {
66
+ const html = render({ initialScope: "standing" });
67
+ // The standing scope's "Always keep" phrasing only renders when it is active.
68
+ assert.match(html, /Always keep/);
69
+ });
57
70
  });
58
71
 
59
72
  // The commit button's `disabled` prop is
@@ -26,6 +26,22 @@ interface OrganizePanelProps {
26
26
  anchorMessageId: string;
27
27
  /** Similar messages the widen preview matched. */
28
28
  matchedCount: number;
29
+ /**
30
+ * Which scope is selected on open. Defaults to `all-like-these`; the
31
+ * zero-match fallback and a "Something else" seed override it.
32
+ */
33
+ initialScope?: OrganizeScope;
34
+ /**
35
+ * Destination folder to pre-select — a "Something else" shortcut or a
36
+ * plain-language input seeds this (issue #211).
37
+ */
38
+ seedMailboxId?: string;
39
+ /**
40
+ * The widen matched nothing, so the sentence organizes just the selection
41
+ * rather than dead-ending. Changes the heading and defaults the scope to
42
+ * `just-these`.
43
+ */
44
+ fallback?: boolean;
29
45
  onClose: () => void;
30
46
  }
31
47
 
@@ -58,10 +74,17 @@ export function OrganizePanel({
58
74
  selectedMessageIds,
59
75
  anchorMessageId,
60
76
  matchedCount,
77
+ initialScope,
78
+ seedMailboxId,
79
+ fallback = false,
61
80
  onClose,
62
81
  }: OrganizePanelProps) {
63
- const [scope, setScope] = useState<OrganizeScope>("all-like-these");
64
- const [moveMailboxId, setMoveMailboxId] = useState<string>("");
82
+ const [scope, setScope] = useState<OrganizeScope>(
83
+ initialScope ?? (fallback ? "just-these" : "all-like-these"),
84
+ );
85
+ const [moveMailboxId, setMoveMailboxId] = useState<string>(
86
+ seedMailboxId ?? "",
87
+ );
65
88
  const [name, setName] = useState("");
66
89
  const [pickedDate, setPickedDate] = useState("");
67
90
 
@@ -155,8 +178,11 @@ export function OrganizePanel({
155
178
  <div className="border-b border-line px-5 py-3">
156
179
  <h2 className="text-sm font-semibold text-fg">Organize</h2>
157
180
  <p className="mt-0.5 text-xs text-fg-muted">
158
- {matchedCount} similar message{matchedCount === 1 ? "" : "s"} found
159
- {selectionCount > 0 ? ` from ${selectionCount} selected` : ""}.
181
+ {fallback
182
+ ? `No similar messages found organizing just your ${selectionCount} selected.`
183
+ : `${matchedCount} similar message${matchedCount === 1 ? "" : "s"} found${
184
+ selectionCount > 0 ? ` from ${selectionCount} selected` : ""
185
+ }.`}
160
186
  </p>
161
187
  </div>
162
188
 
@@ -0,0 +1,54 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import React, { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import type { OrganizeSeed } from "@/lib/organize/mobile-organize-flow";
6
+ import { type FolderOption, SomethingElsePanel } from "./SomethingElsePanel";
7
+
8
+ // The node test loader transpiles remit-ui's `.tsx` with the classic JSX
9
+ // runtime, which references a global `React`. Vite uses the automatic runtime,
10
+ // so this shim only exists for the SSR test harness.
11
+ (globalThis as { React?: typeof React }).React = React;
12
+
13
+ const FOLDERS: FolderOption[] = [
14
+ { id: "mbx-inbox", label: "Inbox" },
15
+ { id: "mbx-archive", label: "Archive" },
16
+ { id: "mbx-junk", label: "Spam" },
17
+ ];
18
+
19
+ const render = (
20
+ folderOptions: FolderOption[] = FOLDERS,
21
+ junkMailboxId?: string,
22
+ ) =>
23
+ renderToString(
24
+ createElement(SomethingElsePanel, {
25
+ folderOptions,
26
+ junkMailboxId,
27
+ onSeed: (_seed: OrganizeSeed) => undefined,
28
+ }) as never,
29
+ );
30
+
31
+ describe("SomethingElsePanel", () => {
32
+ it("shows the plain-language prompt and input", () => {
33
+ const html = render();
34
+ assert.match(html, /What should Remit do\?/);
35
+ assert.match(html, /Tell Remit what to do/);
36
+ });
37
+
38
+ it("derives shortcuts from the account's real folders", () => {
39
+ const html = render();
40
+ assert.match(html, /Always keep in Inbox/);
41
+ assert.match(html, /File in Archive/);
42
+ });
43
+
44
+ it("offers the appointed Junk mailbox by its own label", () => {
45
+ const html = render(FOLDERS, "mbx-junk");
46
+ assert.match(html, /Move to Spam/);
47
+ });
48
+
49
+ it("offers no folder shortcuts when the account has none", () => {
50
+ const html = render([]);
51
+ assert.doesNotMatch(html, /Always keep in Inbox/);
52
+ assert.doesNotMatch(html, /File in Archive/);
53
+ });
54
+ });
@@ -0,0 +1,159 @@
1
+ import { Button, Input } from "@remit/ui";
2
+ import { ArrowUp } from "lucide-react";
3
+ import { useMemo, useState } from "react";
4
+ import type { OrganizeSeed } from "@/lib/organize/mobile-organize-flow";
5
+
6
+ export interface FolderOption {
7
+ id: string;
8
+ label: string;
9
+ }
10
+
11
+ interface SomethingElsePanelProps {
12
+ /** The account's move destinations, the same set the sentence's folder picker uses. */
13
+ folderOptions: FolderOption[];
14
+ /** The appointed Junk mailbox, offered as a shortcut when present. */
15
+ junkMailboxId?: string;
16
+ /** Chosen shortcut or parsed input — seeds the organize sentence's folder/scope. */
17
+ onSeed: (seed: OrganizeSeed) => void;
18
+ }
19
+
20
+ interface Shortcut {
21
+ id: string;
22
+ label: string;
23
+ seed: OrganizeSeed;
24
+ }
25
+
26
+ const findFolder = (
27
+ folderOptions: FolderOption[],
28
+ label: string,
29
+ ): FolderOption | undefined =>
30
+ folderOptions.find((folder) => folder.label.toLowerCase() === label);
31
+
32
+ /**
33
+ * Shortcuts are derived from the account's real folders — there is no
34
+ * suggestion endpoint, so the panel offers the moves it can actually commit:
35
+ * keep it in the Inbox as a standing rule, file it in Archive, or send it to
36
+ * the appointed Junk mailbox. Each seeds the organize sentence; the user still
37
+ * confirms the scope.
38
+ */
39
+ const buildShortcuts = (
40
+ folderOptions: FolderOption[],
41
+ junkMailboxId: string | undefined,
42
+ ): Shortcut[] => {
43
+ const shortcuts: Shortcut[] = [];
44
+ const inbox = findFolder(folderOptions, "inbox");
45
+ if (inbox) {
46
+ shortcuts.push({
47
+ id: "keep-inbox",
48
+ label: "Always keep in Inbox",
49
+ seed: { moveMailboxId: inbox.id, scope: "standing" },
50
+ });
51
+ }
52
+ const archive = findFolder(folderOptions, "archive");
53
+ if (archive) {
54
+ shortcuts.push({
55
+ id: "file-archive",
56
+ label: "File in Archive",
57
+ seed: { moveMailboxId: archive.id },
58
+ });
59
+ }
60
+ const junk = junkMailboxId
61
+ ? folderOptions.find((folder) => folder.id === junkMailboxId)
62
+ : undefined;
63
+ if (junk) {
64
+ shortcuts.push({
65
+ id: "move-junk",
66
+ label: `Move to ${junk.label}`,
67
+ seed: { moveMailboxId: junk.id },
68
+ });
69
+ }
70
+ return shortcuts;
71
+ };
72
+
73
+ /**
74
+ * Reads a plain-language instruction into a seed without an NLP endpoint: it
75
+ * matches the typed words against the account's own folder names and reads an
76
+ * "always"/"keep" phrasing as the standing scope. Whatever it can't resolve to
77
+ * a folder still opens the sentence, where the picker is one tap away — never a
78
+ * dead end.
79
+ */
80
+ const parseInstruction = (
81
+ text: string,
82
+ folderOptions: FolderOption[],
83
+ ): OrganizeSeed => {
84
+ const lower = text.toLowerCase();
85
+ const folder = folderOptions.find((option) =>
86
+ lower.includes(option.label.toLowerCase()),
87
+ );
88
+ const standing = /\b(always|keep|future|from now)\b/.test(lower);
89
+ return {
90
+ moveMailboxId: folder?.id,
91
+ scope: standing ? "standing" : undefined,
92
+ };
93
+ };
94
+
95
+ /**
96
+ * The "Something else" fallback (issue #211): smart shortcuts plus a
97
+ * plain-language input, both of which seed the organize sentence's folder and
98
+ * scope. Presentational — the flow owns the folder data and what a seed does.
99
+ */
100
+ export function SomethingElsePanel({
101
+ folderOptions,
102
+ junkMailboxId,
103
+ onSeed,
104
+ }: SomethingElsePanelProps) {
105
+ const [text, setText] = useState("");
106
+ const shortcuts = useMemo(
107
+ () => buildShortcuts(folderOptions, junkMailboxId),
108
+ [folderOptions, junkMailboxId],
109
+ );
110
+
111
+ const submit = () => {
112
+ const trimmed = text.trim();
113
+ if (!trimmed) return;
114
+ onSeed(parseInstruction(trimmed, folderOptions));
115
+ };
116
+
117
+ return (
118
+ <div className="flex min-h-0 flex-col">
119
+ <div className="border-b border-line px-5 py-3">
120
+ <h2 className="text-sm font-semibold text-fg">What should Remit do?</h2>
121
+ <p className="mt-0.5 text-xs text-fg-muted">
122
+ Pick a shortcut or say it in your own words.
123
+ </p>
124
+ </div>
125
+
126
+ <div className="min-h-0 flex-1 space-y-2 overflow-y-auto px-5 py-4">
127
+ {shortcuts.map((shortcut) => (
128
+ <Button
129
+ key={shortcut.id}
130
+ variant="secondary"
131
+ onClick={() => onSeed(shortcut.seed)}
132
+ className="h-12 w-full justify-start px-4"
133
+ >
134
+ {shortcut.label}
135
+ </Button>
136
+ ))}
137
+ </div>
138
+
139
+ <div className="flex items-center gap-2 border-t border-line px-5 py-3">
140
+ <Input
141
+ value={text}
142
+ onChange={(event) => setText(event.target.value)}
143
+ onKeyDown={(event) => event.key === "Enter" && submit()}
144
+ placeholder="Tell Remit what to do…"
145
+ aria-label="Tell Remit what to do"
146
+ className="flex-1"
147
+ />
148
+ <Button
149
+ variant="primary"
150
+ aria-label="Send"
151
+ onClick={submit}
152
+ disabled={!text.trim()}
153
+ icon={<ArrowUp className="size-4" />}
154
+ className="size-9 shrink-0 px-0"
155
+ />
156
+ </div>
157
+ </div>
158
+ );
159
+ }
@@ -0,0 +1,197 @@
1
+ import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { RemitImapMailboxResponse } from "@remit/api-http-client/types.gen.ts";
3
+ import { BottomSheet } from "@remit/ui";
4
+ import type { Meta, StoryObj } from "@storybook/react-vite";
5
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
+ import type { ReactNode } from "react";
7
+ import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
8
+ import { OrganizePanel } from "./OrganizePanel";
9
+ import type { FolderOption } from "./SomethingElsePanel";
10
+ import { SomethingElsePanel } from "./SomethingElsePanel";
11
+
12
+ /**
13
+ * `Flows/Smart Organize` — the guided select-similar → organize flow (issue
14
+ * #211), rendered from the real web-client components, not a prototype copy:
15
+ * the same {@link OrganizePanel} the desktop dialog and the mobile sheet use,
16
+ * and the same {@link SomethingElsePanel} the flow seeds from. The prototype's
17
+ * interactive `Inbox` / `Walkthrough` stories live alongside these in the
18
+ * workbench; these show each in-sheet panel in isolation so they can't drift
19
+ * from what ships.
20
+ */
21
+
22
+ const ACCOUNT_ID = "acc-1";
23
+
24
+ const makeMailbox = (
25
+ mailboxId: string,
26
+ fullPath: string,
27
+ ): RemitImapMailboxResponse => ({
28
+ mailboxId,
29
+ accountId: ACCOUNT_ID,
30
+ namespaceType: "personal",
31
+ namespacePrefix: "",
32
+ hierarchyDelimiter: "/",
33
+ fullPath,
34
+ messageCount: 0,
35
+ unseenCount: 0,
36
+ deletedCount: 0,
37
+ lastSyncUid: 0,
38
+ highWaterMarkUid: 0,
39
+ lastMessageSyncAt: 0,
40
+ createdAt: 0,
41
+ updatedAt: 0,
42
+ });
43
+
44
+ const MAILBOXES: RemitImapMailboxResponse[] = [
45
+ makeMailbox("mbx-inbox", "INBOX"),
46
+ makeMailbox("mbx-archive", "Archive"),
47
+ makeMailbox("mbx-receipts", "Receipts"),
48
+ makeMailbox("mbx-travel", "Travel"),
49
+ makeMailbox("mbx-junk", "Junk"),
50
+ ];
51
+
52
+ const FOLDER_OPTIONS: FolderOption[] = [
53
+ { id: "mbx-inbox", label: "Inbox" },
54
+ { id: "mbx-archive", label: "Archive" },
55
+ { id: "mbx-receipts", label: "Receipts" },
56
+ { id: "mbx-junk", label: "Junk" },
57
+ ];
58
+
59
+ /**
60
+ * A QueryClient pre-seeded with the account's mailboxes, so the sentence's
61
+ * folder picker renders its real options without a network round-trip.
62
+ */
63
+ function seededClient(): QueryClient {
64
+ const client = new QueryClient({
65
+ defaultOptions: { queries: { retry: false } },
66
+ });
67
+ client.setQueryData(
68
+ mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT_ID } }),
69
+ { items: MAILBOXES },
70
+ );
71
+ return client;
72
+ }
73
+
74
+ /** Phone frame + a bottom sheet holding the panel, mirroring the mobile home. */
75
+ function SheetStage({ children }: { children: ReactNode }) {
76
+ return (
77
+ <QueryClientProvider client={seededClient()}>
78
+ <ErrorBannerProvider>
79
+ <div className="relative mx-auto h-dvh w-full shrink-0 overflow-hidden bg-surface sm:my-6 sm:h-[760px] sm:w-[390px] sm:rounded-[2rem] sm:border sm:border-line sm:shadow-sm">
80
+ <div className="divide-y divide-line opacity-50">
81
+ {Array.from({ length: 8 }).map((_, index) => (
82
+ <div
83
+ // biome-ignore lint/suspicious/noArrayIndexKey: static backdrop skeleton, no ids
84
+ key={index}
85
+ className="flex items-start gap-3 px-row-inset py-2.5"
86
+ >
87
+ <div className="mt-0.5 size-7 shrink-0 rounded-full bg-surface-sunken" />
88
+ <div className="min-w-0 flex-1 space-y-1">
89
+ <div className="h-2.5 w-1/3 rounded bg-surface-sunken" />
90
+ <div className="h-2 w-2/3 rounded bg-surface-sunken" />
91
+ </div>
92
+ </div>
93
+ ))}
94
+ </div>
95
+ <BottomSheet open onClose={() => undefined}>
96
+ {children}
97
+ </BottomSheet>
98
+ </div>
99
+ </ErrorBannerProvider>
100
+ </QueryClientProvider>
101
+ );
102
+ }
103
+
104
+ const meta: Meta = {
105
+ title: "Flows/Smart Organize",
106
+ parameters: { layout: "fullscreen" },
107
+ };
108
+ export default meta;
109
+
110
+ type Story = StoryObj;
111
+
112
+ /** The organize sentence on a widened selection — folder picker + four scopes. */
113
+ export const Organize: Story = {
114
+ render: () => (
115
+ <SheetStage>
116
+ <OrganizePanel
117
+ accountId={ACCOUNT_ID}
118
+ mailboxId="mbx-inbox"
119
+ selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
120
+ anchorMessageId="msg-1"
121
+ matchedCount={47}
122
+ onClose={() => undefined}
123
+ />
124
+ </SheetStage>
125
+ ),
126
+ };
127
+
128
+ /**
129
+ * A larger widen — the same sentence over a broad match set, as when the anchor
130
+ * is a common sender.
131
+ */
132
+ export const FromSearch: Story = {
133
+ name: "From Search",
134
+ render: () => (
135
+ <SheetStage>
136
+ <OrganizePanel
137
+ accountId={ACCOUNT_ID}
138
+ mailboxId="mbx-inbox"
139
+ selectedMessageIds={["msg-1"]}
140
+ anchorMessageId="msg-1"
141
+ matchedCount={412}
142
+ seedMailboxId="mbx-archive"
143
+ onClose={() => undefined}
144
+ />
145
+ </SheetStage>
146
+ ),
147
+ };
148
+
149
+ /** The standing scope pre-selected — the "Always keep…" sentence + filter name. */
150
+ export const AlwaysRule: Story = {
151
+ name: "Always Rule",
152
+ render: () => (
153
+ <SheetStage>
154
+ <OrganizePanel
155
+ accountId={ACCOUNT_ID}
156
+ mailboxId="mbx-inbox"
157
+ selectedMessageIds={["msg-1", "msg-2"]}
158
+ anchorMessageId="msg-1"
159
+ matchedCount={47}
160
+ initialScope="standing"
161
+ seedMailboxId="mbx-travel"
162
+ onClose={() => undefined}
163
+ />
164
+ </SheetStage>
165
+ ),
166
+ };
167
+
168
+ /** The widen matched nothing — the sentence organizes just the selection. */
169
+ export const NoSimilarFound: Story = {
170
+ name: "No Similar Found",
171
+ render: () => (
172
+ <SheetStage>
173
+ <OrganizePanel
174
+ accountId={ACCOUNT_ID}
175
+ mailboxId="mbx-inbox"
176
+ selectedMessageIds={["msg-1", "msg-2"]}
177
+ anchorMessageId="msg-1"
178
+ matchedCount={0}
179
+ fallback
180
+ onClose={() => undefined}
181
+ />
182
+ </SheetStage>
183
+ ),
184
+ };
185
+
186
+ /** The "Something else" fallback: shortcuts derived from folders + an input. */
187
+ export const SomethingElse: Story = {
188
+ render: () => (
189
+ <SheetStage>
190
+ <SomethingElsePanel
191
+ folderOptions={FOLDER_OPTIONS}
192
+ junkMailboxId="mbx-junk"
193
+ onSeed={() => undefined}
194
+ />
195
+ </SheetStage>
196
+ ),
197
+ };
@@ -0,0 +1,96 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ type OrganizeStageInput,
5
+ resolveOrganizeStage,
6
+ } from "./mobile-organize-flow";
7
+
8
+ const base: OrganizeStageInput = {
9
+ entry: "select-similar",
10
+ hasSeed: true,
11
+ previewStatus: "idle",
12
+ matchedCount: undefined,
13
+ };
14
+
15
+ describe("resolveOrganizeStage — the guided mobile flow's state machine", () => {
16
+ it("select-similar shows the widening state before the preview resolves", () => {
17
+ assert.deepEqual(resolveOrganizeStage({ ...base, previewStatus: "idle" }), {
18
+ kind: "widening",
19
+ });
20
+ assert.deepEqual(
21
+ resolveOrganizeStage({ ...base, previewStatus: "pending" }),
22
+ { kind: "widening" },
23
+ );
24
+ });
25
+
26
+ it("opens the organize sentence on the widened set once the preview matches", () => {
27
+ assert.deepEqual(
28
+ resolveOrganizeStage({
29
+ ...base,
30
+ previewStatus: "success",
31
+ matchedCount: 47,
32
+ }),
33
+ { kind: "organize", matchedCount: 47, fallback: false },
34
+ );
35
+ });
36
+
37
+ it("falls back to organizing the selection when the widen matches nothing — no dead end", () => {
38
+ assert.deepEqual(
39
+ resolveOrganizeStage({
40
+ ...base,
41
+ previewStatus: "success",
42
+ matchedCount: 0,
43
+ }),
44
+ { kind: "organize", matchedCount: 0, fallback: true },
45
+ );
46
+ // A success with an undefined count is treated as zero, not a crash.
47
+ assert.deepEqual(
48
+ resolveOrganizeStage({
49
+ ...base,
50
+ previewStatus: "success",
51
+ matchedCount: undefined,
52
+ }),
53
+ { kind: "organize", matchedCount: 0, fallback: true },
54
+ );
55
+ });
56
+
57
+ it("surfaces the error branch when the widen fails", () => {
58
+ assert.deepEqual(
59
+ resolveOrganizeStage({ ...base, previewStatus: "error" }),
60
+ { kind: "error" },
61
+ );
62
+ });
63
+
64
+ it("something-else shows the shortcuts + input until a seed is chosen", () => {
65
+ assert.deepEqual(
66
+ resolveOrganizeStage({
67
+ entry: "something-else",
68
+ hasSeed: false,
69
+ previewStatus: "success",
70
+ matchedCount: 47,
71
+ }),
72
+ { kind: "something-else" },
73
+ );
74
+ });
75
+
76
+ it("something-else widens after the seed, then opens the seeded sentence", () => {
77
+ assert.deepEqual(
78
+ resolveOrganizeStage({
79
+ entry: "something-else",
80
+ hasSeed: true,
81
+ previewStatus: "pending",
82
+ matchedCount: undefined,
83
+ }),
84
+ { kind: "widening" },
85
+ );
86
+ assert.deepEqual(
87
+ resolveOrganizeStage({
88
+ entry: "something-else",
89
+ hasSeed: true,
90
+ previewStatus: "success",
91
+ matchedCount: 12,
92
+ }),
93
+ { kind: "organize", matchedCount: 12, fallback: false },
94
+ );
95
+ });
96
+ });
@@ -0,0 +1,73 @@
1
+ import type { OrganizeScope } from "./organize-model";
2
+
3
+ /**
4
+ * How the guided mobile flow was entered from the selection sheet:
5
+ *
6
+ * - `select-similar` — widen straight into the organize sentence.
7
+ * - `something-else` — collect a folder/scope seed from shortcuts or a
8
+ * plain-language input first, then widen into the seeded sentence.
9
+ */
10
+ export type OrganizeEntry = "select-similar" | "something-else";
11
+
12
+ /** The read-only widen preview's lifecycle (POST /organize/preview). */
13
+ export type PreviewStatus = "idle" | "pending" | "success" | "error";
14
+
15
+ /** A folder/scope the "Something else" panel pre-fills the sentence with. */
16
+ export interface OrganizeSeed {
17
+ moveMailboxId?: string;
18
+ scope?: OrganizeScope;
19
+ }
20
+
21
+ /**
22
+ * The stage the guided flow renders, resolved from the entry, whether a
23
+ * "Something else" seed has been chosen yet, and where the widen preview is:
24
+ *
25
+ * - `something-else` — the shortcuts + input, shown until a seed is picked.
26
+ * - `widening` — the brief widening state between the tap and the sentence.
27
+ * - `error` — the widen failed; a dead end only in the sense that it offers a
28
+ * close, never a broken sentence.
29
+ * - `organize` — the committed sentence, on the widened set. `fallback` is true
30
+ * when the widen matched nothing, so the sentence organizes just the
31
+ * selection instead of a dead end (issue #211).
32
+ */
33
+ export type OrganizeStage =
34
+ | { kind: "something-else" }
35
+ | { kind: "widening" }
36
+ | { kind: "error" }
37
+ | { kind: "organize"; matchedCount: number; fallback: boolean };
38
+
39
+ export interface OrganizeStageInput {
40
+ entry: OrganizeEntry;
41
+ /** "Something else" only: whether the user has chosen a seed yet. */
42
+ hasSeed: boolean;
43
+ previewStatus: PreviewStatus;
44
+ /** The widen's matched total; defined once `previewStatus` is `success`. */
45
+ matchedCount: number | undefined;
46
+ }
47
+
48
+ /**
49
+ * The pure state machine behind the guided organize sheet:
50
+ * `idle → widening → organize` for select-similar, with a `something-else`
51
+ * seeding step in front for that entry, an `error` branch when the widen
52
+ * fails, and the zero-match `fallback` that keeps the organize sentence usable
53
+ * on the selection alone. Kept pure so every transition is testable without a
54
+ * DOM, React Query, or a network — the component only reads the stage back.
55
+ */
56
+ export const resolveOrganizeStage = ({
57
+ entry,
58
+ hasSeed,
59
+ previewStatus,
60
+ matchedCount,
61
+ }: OrganizeStageInput): OrganizeStage => {
62
+ if (entry === "something-else" && !hasSeed) {
63
+ return { kind: "something-else" };
64
+ }
65
+ if (previewStatus === "error") {
66
+ return { kind: "error" };
67
+ }
68
+ if (previewStatus !== "success") {
69
+ return { kind: "widening" };
70
+ }
71
+ const matched = matchedCount ?? 0;
72
+ return { kind: "organize", matchedCount: matched, fallback: matched === 0 };
73
+ };
@@ -0,0 +1,105 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import {
4
+ resolveSelectionSheetMode,
5
+ SELECTION_SHEET_MIN_COUNT,
6
+ shouldShowSelectionSheet,
7
+ } from "./selection-sheet-mode";
8
+
9
+ describe("resolveSelectionSheetMode", () => {
10
+ test("is idle for a plain bounded selection", () => {
11
+ assert.equal(
12
+ resolveSelectionSheetMode({
13
+ isRunning: false,
14
+ isCounting: false,
15
+ isEscalated: false,
16
+ }),
17
+ "idle",
18
+ );
19
+ });
20
+
21
+ test("is escalated once the selection is the search predicate", () => {
22
+ assert.equal(
23
+ resolveSelectionSheetMode({
24
+ isRunning: false,
25
+ isCounting: false,
26
+ isEscalated: true,
27
+ }),
28
+ "escalated",
29
+ );
30
+ });
31
+
32
+ test("is counting while the predicate is still paging to a total", () => {
33
+ assert.equal(
34
+ resolveSelectionSheetMode({
35
+ isRunning: false,
36
+ isCounting: true,
37
+ isEscalated: false,
38
+ }),
39
+ "counting",
40
+ );
41
+ });
42
+
43
+ test("running wins over escalated — a chunked run is both", () => {
44
+ assert.equal(
45
+ resolveSelectionSheetMode({
46
+ isRunning: true,
47
+ isCounting: false,
48
+ isEscalated: true,
49
+ }),
50
+ "running",
51
+ );
52
+ });
53
+
54
+ test("running wins over counting", () => {
55
+ assert.equal(
56
+ resolveSelectionSheetMode({
57
+ isRunning: true,
58
+ isCounting: true,
59
+ isEscalated: false,
60
+ }),
61
+ "running",
62
+ );
63
+ });
64
+
65
+ test("counting wins over escalated", () => {
66
+ assert.equal(
67
+ resolveSelectionSheetMode({
68
+ isRunning: false,
69
+ isCounting: true,
70
+ isEscalated: true,
71
+ }),
72
+ "counting",
73
+ );
74
+ });
75
+ });
76
+
77
+ describe("shouldShowSelectionSheet", () => {
78
+ test("hides at a single selected row while idle", () => {
79
+ assert.equal(shouldShowSelectionSheet(1, "idle"), false);
80
+ });
81
+
82
+ test("shows at the two-row threshold", () => {
83
+ assert.equal(
84
+ shouldShowSelectionSheet(SELECTION_SHEET_MIN_COUNT, "idle"),
85
+ true,
86
+ );
87
+ });
88
+
89
+ test("shows for any non-idle state even below the threshold", () => {
90
+ assert.equal(shouldShowSelectionSheet(0, "counting"), true);
91
+ assert.equal(shouldShowSelectionSheet(1, "running"), true);
92
+ assert.equal(shouldShowSelectionSheet(0, "escalated"), true);
93
+ });
94
+
95
+ test("shows a pending notice at a single idle row so it is never dropped", () => {
96
+ // A bulk delete that leaves one message behind returns to idle at count 1
97
+ // with a "1 couldn't be deleted / Retry" notice; the escalation offer can
98
+ // likewise sit at one loaded row. The sheet must mount to surface either.
99
+ assert.equal(shouldShowSelectionSheet(1, "idle", true), true);
100
+ });
101
+
102
+ test("still hides a single idle row with no notice", () => {
103
+ assert.equal(shouldShowSelectionSheet(1, "idle", false), false);
104
+ });
105
+ });
@@ -0,0 +1,49 @@
1
+ import type { SelectionSheetMode } from "@remit/ui";
2
+
3
+ /**
4
+ * Routes the mobile selection sheet to one of its four content states from the
5
+ * escalation machinery's flags. Pure so the routing — which is what decides
6
+ * whether the sheet shows idle quick actions or the counting / running /
7
+ * escalated status — is testable without the sheet or a DOM.
8
+ *
9
+ * Precedence, highest first:
10
+ * 1. `running` — a chunked delete/move/mark-read is in flight; progress owns
11
+ * the sheet even over an escalated selection (an escalated run is both).
12
+ * 2. `counting` — a search predicate is still paging to its total.
13
+ * 3. `escalated` — the selection is the search predicate, not a loaded id set.
14
+ * 4. `idle` — a bounded selection with the quick actions and smart-flow rows.
15
+ */
16
+ export const resolveSelectionSheetMode = (input: {
17
+ isRunning: boolean;
18
+ isCounting: boolean;
19
+ isEscalated: boolean;
20
+ }): SelectionSheetMode => {
21
+ if (input.isRunning) return "running";
22
+ if (input.isCounting) return "counting";
23
+ if (input.isEscalated) return "escalated";
24
+ return "idle";
25
+ };
26
+
27
+ /**
28
+ * The count threshold the peeking teaser rises at — two or more selected, the
29
+ * prototype's threshold. Below it selection mode is still entered (rows show
30
+ * their checkboxes) but no sheet appears, matching today's single-select
31
+ * behaviour of leaving the list chrome alone.
32
+ */
33
+ export const SELECTION_SHEET_MIN_COUNT = 2;
34
+
35
+ /**
36
+ * Whether the mobile sheet should be mounted: two or more rows selected, or any
37
+ * non-idle escalation state (counting, running, escalated), or a pending notice
38
+ * that has to be surfaced. The last case is why a single-row selection can still
39
+ * raise the sheet — a bulk run that leaves exactly one message behind returns to
40
+ * idle at count 1 with a "1 couldn't be deleted / Retry" notice, and a search
41
+ * with one loaded row plus more matches carries the escalation offer at count 1.
42
+ * Suppressing the sheet there would drop the notice with no way to retry.
43
+ */
44
+ export const shouldShowSelectionSheet = (
45
+ count: number,
46
+ mode: SelectionSheetMode,
47
+ hasNotice = false,
48
+ ): boolean =>
49
+ count >= SELECTION_SHEET_MIN_COUNT || mode !== "idle" || hasNotice;