@remit/web-client 0.0.56 → 0.0.58

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.
@@ -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
+ };
@@ -62,8 +62,8 @@ interface UseEscalatedActionsOptions {
62
62
  mailboxId: string;
63
63
  /** Owning account, forwarded to the unseen-count invalidation on completion. */
64
64
  accountId?: string;
65
- /** Disables escalation entirely (e.g. not searching, or desktop — this is a
66
- * mobile-only affordance). Resets any in-flight phase back to idle. */
65
+ /** Disables escalation entirely (e.g. not searching). Resets any in-flight
66
+ * phase back to idle. */
67
67
  enabled: boolean;
68
68
  /** Identifies the active predicate; escalation resets to idle whenever this
69
69
  * changes (a different search is a different question). */
@@ -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
+ };