@remit/web-client 0.0.56 → 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.56",
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": {
@@ -53,6 +53,7 @@ import {
53
53
  } from "@/lib/selection-sheet-mode";
54
54
  import { cn } from "@/lib/utils";
55
55
  import { MoveToTrigger } from "./MoveToTrigger";
56
+ import { MobileOrganizeFlow } from "./organize/MobileOrganizeFlow";
56
57
  import { OrganizeDialog } from "./organize/OrganizeDialog";
57
58
  import { SelectionToolbar } from "./SelectionToolbar";
58
59
  import { SwipeableMessageRow } from "./SwipeableMessageRow";
@@ -221,6 +222,12 @@ export const MessageList = ({
221
222
  const navigate = useNavigate();
222
223
  const isDesktop = useIsDesktop();
223
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);
224
231
  const isSearching = !!searchQuery?.trim();
225
232
 
226
233
  // Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
@@ -275,6 +282,13 @@ export const MessageList = ({
275
282
  // it back to the count, and across that render the two disagree.
276
283
  const isMultiSelectMode = deriveIsMultiSelectMode(selectedCount, isDesktop);
277
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
+
278
292
  // Pending delete, awaiting confirmation. `null` means the dialog is closed.
279
293
  // `source: "ids"` snapshots the concrete ids at request time so a selection
280
294
  // change behind the dialog can't retarget the delete — every keyboard/desktop
@@ -1227,6 +1241,24 @@ export const MessageList = ({
1227
1241
  />
1228
1242
  ) : undefined;
1229
1243
 
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
+
1230
1262
  const mobileSelectionSheet = showMobileSheet ? (
1231
1263
  <SelectionSheet
1232
1264
  count={mobileCount}
@@ -1239,8 +1271,12 @@ export const MessageList = ({
1239
1271
  : undefined
1240
1272
  }
1241
1273
  onMarkRead={handleMarkAsRead}
1242
- onSelectSimilar={accountId ? () => setOrganizeOpen(true) : undefined}
1243
- onSomethingElse={accountId ? () => setOrganizeOpen(true) : undefined}
1274
+ onSelectSimilar={
1275
+ accountId ? () => setMobileOrganizeEntry("select-similar") : undefined
1276
+ }
1277
+ onSomethingElse={
1278
+ accountId ? () => setMobileOrganizeEntry("something-else") : undefined
1279
+ }
1244
1280
  moveSlot={mobileMoveSlot}
1245
1281
  isBusy={mobileIsBusy}
1246
1282
  selectAll={mobileSelectAll}
@@ -1258,6 +1294,16 @@ export const MessageList = ({
1258
1294
  />
1259
1295
  ) : undefined;
1260
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
+
1261
1307
  const activeSelectionBar = desktopSelectionBar;
1262
1308
 
1263
1309
  // Roving tabindex: exactly one row is in the tab order, so Tab moves focus
@@ -1401,7 +1447,7 @@ export const MessageList = ({
1401
1447
  isDesktop={isDesktop}
1402
1448
  hideHeader={hideHeader}
1403
1449
  selectionBar={activeSelectionBar}
1404
- selectionSheet={mobileSelectionSheet}
1450
+ selectionSheet={mobileSelectionSurface}
1405
1451
  listBody={listState === "ready" ? virtualBody : undefined}
1406
1452
  />
1407
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
+ };