@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.
@@ -0,0 +1,194 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { SelectionToolbar } from "./SelectionToolbar";
3
+
4
+ /**
5
+ * The desktop bulk-action bar. It keeps its bounded verbs (mark-read, move,
6
+ * delete, organize) and, from issue #212, gains the search-escalation states
7
+ * the mobile `SelectionSheet` already carries: the "Select all N matching…"
8
+ * offer, a running count, the escalated predicate, a chunked run's progress,
9
+ * and a partial-failure Retry. Both surfaces read the same derivations in
10
+ * `MessageList`, so these stories and the kit `SelectionTopBar` stories track
11
+ * the one state matrix.
12
+ *
13
+ * The Move verb needs the account-scoped `MoveToTrigger` (its own query and
14
+ * folder data), so it is left out here to keep these stories provider-free —
15
+ * its visual is covered by the kit `SelectionTopBar` stories' move slot. The
16
+ * escalated stories below still assert verb parity through the mark-read and
17
+ * delete verbs remaining available over the predicate.
18
+ */
19
+ const meta: Meta<typeof SelectionToolbar> = {
20
+ title: "Screens/WebClient/SelectionToolbar",
21
+ component: SelectionToolbar,
22
+ parameters: { layout: "padded" },
23
+ args: {
24
+ selectedCount: 3,
25
+ onDelete: () => undefined,
26
+ onClearSelection: () => undefined,
27
+ onMarkAsRead: () => undefined,
28
+ },
29
+ render: (args) => (
30
+ <div className="w-full rounded-md border border-line">
31
+ <SelectionToolbar {...args} />
32
+ </div>
33
+ ),
34
+ };
35
+ export default meta;
36
+
37
+ type Story = StoryObj<typeof SelectionToolbar>;
38
+
39
+ /** A plain bounded selection: the count in words, mark-read and delete. */
40
+ export const Bounded: Story = {};
41
+
42
+ export const OneSelected: Story = { args: { selectedCount: 1 } };
43
+
44
+ /**
45
+ * Some but not all loaded rows checked while searching: the select-all control
46
+ * renders the tri-state dash.
47
+ */
48
+ export const SelectAllIndeterminate: Story = {
49
+ args: {
50
+ selectAll: {
51
+ checked: false,
52
+ indeterminate: true,
53
+ onChange: () => undefined,
54
+ },
55
+ },
56
+ };
57
+
58
+ /**
59
+ * Every loaded row checked. The label names the loaded scope — "All 47 loaded
60
+ * selected" — rather than a bare count next to a fully ticked box.
61
+ */
62
+ export const AllLoadedSelected: Story = {
63
+ args: {
64
+ selectedCount: 47,
65
+ selectAll: { checked: true, onChange: () => undefined },
66
+ },
67
+ };
68
+
69
+ /**
70
+ * Search has more matches than are loaded: an escalation offer naming the
71
+ * scope (a real button, not prose). Tapping it pays for the real count.
72
+ */
73
+ export const EscalationAvailable: Story = {
74
+ args: {
75
+ selectedCount: 47,
76
+ selectAll: { checked: true, onChange: () => undefined },
77
+ notice: {
78
+ tone: "info",
79
+ text: "",
80
+ action: {
81
+ label: 'Select all matching "npm"',
82
+ onClick: () => undefined,
83
+ },
84
+ },
85
+ },
86
+ };
87
+
88
+ /**
89
+ * The result set is still paging to its total: a running count, Delete hidden
90
+ * (the count it would act on isn't known yet), and an explicit Stop.
91
+ */
92
+ export const Counting: Story = {
93
+ args: {
94
+ selectedCount: 47,
95
+ isCounting: true,
96
+ statusLabel: "Counting… 1,900 so far",
97
+ selectAll: { checked: true, onChange: () => undefined },
98
+ notice: {
99
+ tone: "info",
100
+ text: "",
101
+ action: { label: "Stop", onClick: () => undefined },
102
+ },
103
+ },
104
+ };
105
+
106
+ /** Past ~10s the counting state says so rather than looking stuck. */
107
+ export const CountingLargeResultSet: Story = {
108
+ args: {
109
+ selectedCount: 0,
110
+ isCounting: true,
111
+ statusLabel: "Counting… 12,400 so far. This is a big result set.",
112
+ notice: {
113
+ tone: "info",
114
+ text: "",
115
+ action: { label: "Stop", onClick: () => undefined },
116
+ },
117
+ },
118
+ };
119
+
120
+ /**
121
+ * The selection is now the search predicate: the count names the query's total,
122
+ * every verb stays available over it (#114 — not delete-only), and the notice
123
+ * offers a way back to the bounded selection.
124
+ */
125
+ export const Escalated: Story = {
126
+ args: {
127
+ selectedCount: 3412,
128
+ statusLabel: 'All 3,412 matching "npm" selected',
129
+ notice: {
130
+ tone: "info",
131
+ text: "",
132
+ action: { label: "Clear selection", onClick: () => undefined },
133
+ },
134
+ },
135
+ };
136
+
137
+ /**
138
+ * A chunked delete over the predicate: a running total and a determinate
139
+ * progress bar, the verbs off screen (the bar is the only thing that can act
140
+ * mid-run), toned as destructive.
141
+ */
142
+ export const DeletingWithProgress: Story = {
143
+ args: {
144
+ selectedCount: 3412,
145
+ statusLabel: "Deleting 1,200 of 3,412…",
146
+ progress: { value: 1200, max: 3412, tone: "danger" },
147
+ },
148
+ };
149
+
150
+ /** A move over the escalated selection: the same run, toned as ordinary
151
+ * progress and worded for the action that is running. */
152
+ export const MovingWithProgress: Story = {
153
+ args: {
154
+ selectedCount: 3412,
155
+ statusLabel: "Moving 1,200 of 3,412…",
156
+ progress: { value: 1200, max: 3412, tone: "info" },
157
+ },
158
+ };
159
+
160
+ /** Mark-read over the escalated selection. */
161
+ export const MarkingReadWithProgress: Story = {
162
+ args: {
163
+ selectedCount: 3412,
164
+ statusLabel: "Marking 1,200 of 3,412 as read…",
165
+ progress: { value: 1200, max: 3412, tone: "info" },
166
+ },
167
+ };
168
+
169
+ /**
170
+ * A run left some messages unreached: the notice names how many landed, the
171
+ * count reflects only what is still selected (the failures), and Retry targets
172
+ * exactly that.
173
+ */
174
+ export const PartialFailure: Story = {
175
+ args: {
176
+ selectedCount: 340,
177
+ notice: {
178
+ tone: "danger",
179
+ text: "3,072 moved to Trash. 340 couldn't be deleted.",
180
+ action: { label: "Retry 340", onClick: () => undefined },
181
+ },
182
+ },
183
+ };
184
+
185
+ /**
186
+ * A selection spanning accounts: Move is withdrawn (it only works within one
187
+ * account) and the reason is stated inline.
188
+ */
189
+ export const CrossAccountHint: Story = {
190
+ args: {
191
+ moveDisabledHint:
192
+ "Move only works within one account — clear selection or pick messages from a single account",
193
+ },
194
+ };
@@ -1,7 +1,14 @@
1
+ import { Banner, type BannerTone, Checkbox, ProgressBar } from "@remit/ui";
1
2
  import { Loader2, MailOpen, Sparkles, Trash2, X } from "lucide-react";
2
3
  import { cn } from "@/lib/utils";
3
4
  import { MoveToTrigger } from "./MoveToTrigger";
4
5
 
6
+ export interface SelectionToolbarNotice {
7
+ tone: BannerTone;
8
+ text: string;
9
+ action?: { label: string; onClick: () => void };
10
+ }
11
+
5
12
  interface SelectionToolbarProps {
6
13
  selectedCount: number;
7
14
  onDelete: () => void;
@@ -33,6 +40,42 @@ interface SelectionToolbarProps {
33
40
  * Move only works within one account.
34
41
  */
35
42
  moveDisabledHint?: string;
43
+ /**
44
+ * Select-all-loaded control, rendered between the clear button and the
45
+ * count. Presence renders the checkbox; `indeterminate` is the some-selected
46
+ * tri-state. Wired only while searching, where escalating past the loaded
47
+ * page is possible (issue #212).
48
+ */
49
+ selectAll?: {
50
+ checked: boolean;
51
+ indeterminate?: boolean;
52
+ onChange: () => void;
53
+ };
54
+ /**
55
+ * Overrides the "{count} messages selected" text. Names the scope once it is
56
+ * anything other than a materialized selection — an escalated predicate
57
+ * ("All 3,412 matching \"npm\" selected"), a running count ("Counting… 1,900
58
+ * so far"), or bulk-run progress ("Deleting 1,200 of 3,412…").
59
+ */
60
+ statusLabel?: string;
61
+ /**
62
+ * True while a search result set is still paging to its total. Hides Delete
63
+ * (and Move) — the count they would act on isn't known yet.
64
+ */
65
+ isCounting?: boolean;
66
+ /**
67
+ * Determinate progress for a chunked/escalated run in flight. Renders a
68
+ * `ProgressBar` below the action row and takes the verbs off screen — the
69
+ * bar is the only thing that can act mid-run.
70
+ */
71
+ progress?: { value: number; max: number; tone?: BannerTone };
72
+ /**
73
+ * At-most-one toned status line below the action row, optionally carrying an
74
+ * action button — the "Select all N matching…" escalation offer, a "Stop"
75
+ * during counting, a "Clear selection" once escalated, or a partial-failure
76
+ * "Retry N" (issue #212).
77
+ */
78
+ notice?: SelectionToolbarNotice;
36
79
  }
37
80
 
38
81
  export const SelectionToolbar = ({
@@ -47,93 +90,164 @@ export const SelectionToolbar = ({
47
90
  accountId,
48
91
  currentMailboxId,
49
92
  moveDisabledHint,
93
+ selectAll,
94
+ statusLabel,
95
+ isCounting = false,
96
+ progress,
97
+ notice,
50
98
  }: SelectionToolbarProps) => {
51
- const isBusy = isDeleting || isMoving;
52
99
  if (selectedCount === 0) return null;
53
100
 
101
+ // A chunked/escalated run owns the bar: the progress bar is the only signal
102
+ // that can act, so the verbs come off screen until it ends (issue #212).
103
+ const isRunning = progress !== undefined;
104
+ const isBusy = isDeleting || isMoving || isRunning;
105
+ // The verbs are suppressed while a run is in flight or a count is still
106
+ // paging; an escalated-but-idle selection keeps every verb (verb parity).
107
+ const showVerbs = !isRunning && !isCounting;
108
+
54
109
  const canShowMove = !!onMove && !!accountId && !!currentMailboxId;
110
+ // Organize has no escalated-predicate path — it acts on the materialized
111
+ // selection — so it's withdrawn the moment the selection escalates or a run
112
+ // takes over (any state that names itself through `statusLabel`).
55
113
  const canShowOrganize =
56
- !!onOrganize && !!accountId && !!currentMailboxId && !moveDisabledHint;
114
+ !!onOrganize &&
115
+ !!accountId &&
116
+ !!currentMailboxId &&
117
+ !moveDisabledHint &&
118
+ !statusLabel;
119
+
120
+ const defaultLabel = selectAll?.checked
121
+ ? `All ${selectedCount} loaded selected`
122
+ : `${selectedCount} ${selectedCount === 1 ? "message" : "messages"} selected`;
57
123
 
58
124
  return (
59
- <div className="sticky top-0 z-10 flex items-center justify-between px-3 py-2 bg-surface-sunken border-b border-line">
60
- <div className="flex items-center gap-3">
61
- <button
62
- type="button"
63
- onClick={onClearSelection}
64
- className="min-h-11 min-w-11 inline-flex items-center justify-center rounded hover:bg-surface-raised transition-colors"
65
- aria-label="Clear selection"
66
- >
67
- <X className="size-4 text-fg-muted" />
68
- </button>
69
- <span className="text-sm font-medium">
70
- {selectedCount} {selectedCount === 1 ? "message" : "messages"}{" "}
71
- selected
72
- </span>
73
- {moveDisabledHint && (
74
- // biome-ignore lint/a11y/useSemanticElements: <span> with role="status" keeps inline layout; <output> would shift flow
75
- <span
76
- className="text-xs text-fg-muted"
77
- role="status"
78
- aria-live="polite"
79
- >
80
- {moveDisabledHint}
81
- </span>
82
- )}
83
- </div>
84
- <div className="flex items-center gap-1">
85
- {canShowOrganize && onOrganize && (
86
- <button
87
- type="button"
88
- onClick={isBusy ? undefined : onOrganize}
89
- className="min-h-11 inline-flex items-center justify-center gap-1.5 px-3 rounded text-sm font-medium transition-colors hover:bg-surface-raised"
90
- aria-label="Organize similar messages"
91
- >
92
- <Sparkles className="size-4" />
93
- <span className="hidden sm:inline">Organize</span>
94
- </button>
95
- )}
96
- {onMarkAsRead && (
125
+ <div className="sticky top-0 z-10 flex flex-col bg-surface-sunken border-b border-line">
126
+ <div className="flex items-center justify-between px-3 py-2">
127
+ <div className="flex items-center gap-3">
97
128
  <button
98
129
  type="button"
99
- onClick={isBusy ? undefined : onMarkAsRead}
100
- className="min-h-11 min-w-11 inline-flex items-center justify-center rounded text-sm font-medium transition-colors hover:bg-surface-raised"
101
- aria-label="Mark as read"
102
- aria-busy={isBusy}
130
+ onClick={onClearSelection}
131
+ className="min-h-11 min-w-11 inline-flex items-center justify-center rounded hover:bg-surface-raised transition-colors"
132
+ aria-label="Clear selection"
103
133
  >
104
- <MailOpen className="size-4" />
134
+ <X className="size-4 text-fg-muted" />
105
135
  </button>
106
- )}
107
- {canShowMove && onMove && accountId && currentMailboxId && (
108
- <MoveToTrigger
109
- accountId={accountId}
110
- currentMailboxId={currentMailboxId}
111
- onMove={isBusy ? () => {} : onMove}
112
- disabledHint={moveDisabledHint}
113
- variant="icon-only"
114
- label="Move selected messages"
115
- />
116
- )}
117
- <button
118
- type="button"
119
- onClick={isBusy ? undefined : onDelete}
120
- className={cn(
121
- "min-h-11 min-w-11 inline-flex items-center justify-center gap-1.5 px-3 rounded text-sm font-medium transition-colors",
122
- "bg-danger text-canvas hover:bg-danger/90",
136
+ {selectAll && (
137
+ // biome-ignore lint/a11y/noLabelWithoutControl: the label wraps Checkbox's own input, giving the 20px control a 44px hit area
138
+ <label className="flex size-11 shrink-0 cursor-pointer items-center justify-center">
139
+ <Checkbox
140
+ aria-label="Select all"
141
+ checked={selectAll.checked}
142
+ indeterminate={selectAll.indeterminate}
143
+ onChange={selectAll.onChange}
144
+ />
145
+ </label>
123
146
  )}
124
- aria-label="Delete selected messages"
125
- aria-busy={isDeleting}
126
- >
127
- {isDeleting ? (
128
- <Loader2 className="size-4 animate-spin" />
129
- ) : (
130
- <Trash2 className="size-4" />
131
- )}
132
- <span className="hidden sm:inline">
133
- {isDeleting ? "Deleting..." : "Delete"}
147
+ <span className="text-sm font-medium">
148
+ {statusLabel ?? defaultLabel}
134
149
  </span>
135
- </button>
150
+ {moveDisabledHint && !notice && (
151
+ <span
152
+ className="text-xs text-fg-muted"
153
+ role="status"
154
+ aria-live="polite"
155
+ >
156
+ {moveDisabledHint}
157
+ </span>
158
+ )}
159
+ </div>
160
+ <div className="flex items-center gap-1">
161
+ {canShowOrganize && onOrganize && (
162
+ <button
163
+ type="button"
164
+ onClick={isBusy ? undefined : onOrganize}
165
+ className="min-h-11 inline-flex items-center justify-center gap-1.5 px-3 rounded text-sm font-medium transition-colors hover:bg-surface-raised"
166
+ aria-label="Organize similar messages"
167
+ >
168
+ <Sparkles className="size-4" />
169
+ <span className="hidden sm:inline">Organize</span>
170
+ </button>
171
+ )}
172
+ {showVerbs && onMarkAsRead && (
173
+ <button
174
+ type="button"
175
+ onClick={isBusy ? undefined : onMarkAsRead}
176
+ className="min-h-11 min-w-11 inline-flex items-center justify-center rounded text-sm font-medium transition-colors hover:bg-surface-raised"
177
+ aria-label="Mark as read"
178
+ aria-busy={isBusy}
179
+ >
180
+ <MailOpen className="size-4" />
181
+ </button>
182
+ )}
183
+ {showVerbs &&
184
+ canShowMove &&
185
+ onMove &&
186
+ accountId &&
187
+ currentMailboxId && (
188
+ <MoveToTrigger
189
+ accountId={accountId}
190
+ currentMailboxId={currentMailboxId}
191
+ onMove={isBusy ? () => {} : onMove}
192
+ disabledHint={moveDisabledHint}
193
+ variant="icon-only"
194
+ label="Move selected messages"
195
+ />
196
+ )}
197
+ {showVerbs && (
198
+ <button
199
+ type="button"
200
+ onClick={isBusy ? undefined : onDelete}
201
+ className={cn(
202
+ "min-h-11 min-w-11 inline-flex items-center justify-center gap-1.5 px-3 rounded text-sm font-medium transition-colors",
203
+ "bg-danger text-canvas hover:bg-danger/90",
204
+ )}
205
+ aria-label="Delete selected messages"
206
+ aria-busy={isDeleting}
207
+ >
208
+ {isDeleting ? (
209
+ <Loader2 className="size-4 animate-spin" />
210
+ ) : (
211
+ <Trash2 className="size-4" />
212
+ )}
213
+ <span className="hidden sm:inline">
214
+ {isDeleting ? "Deleting..." : "Delete"}
215
+ </span>
216
+ </button>
217
+ )}
218
+ </div>
136
219
  </div>
220
+ {progress && (
221
+ <div className="px-3 pb-2">
222
+ <ProgressBar
223
+ value={progress.value}
224
+ max={progress.max}
225
+ tone={progress.tone}
226
+ />
227
+ </div>
228
+ )}
229
+ {notice && (
230
+ <Banner
231
+ tone={notice.tone}
232
+ variant="soft"
233
+ role="status"
234
+ aria-live="polite"
235
+ className="mx-3 mb-2"
236
+ >
237
+ <div className="flex items-center justify-between gap-2">
238
+ {notice.text && <span>{notice.text}</span>}
239
+ {notice.action && (
240
+ <button
241
+ type="button"
242
+ onClick={notice.action.onClick}
243
+ className="-my-1 min-h-11 shrink-0 inline-flex items-center px-3 rounded text-sm font-medium transition-colors hover:bg-surface-raised"
244
+ >
245
+ {notice.action.label}
246
+ </button>
247
+ )}
248
+ </div>
249
+ </Banner>
250
+ )}
137
251
  </div>
138
252
  );
139
253
  };
@@ -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
+ }