@remit/web-client 0.0.86 → 0.0.87

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.
Files changed (69) hide show
  1. package/package.json +1 -1
  2. package/src/auth/BetterAuthShell.tsx +2 -2
  3. package/src/components/compose/AddressField.tsx +60 -77
  4. package/src/components/mail/AutoMovedIndicator.tsx +7 -9
  5. package/src/components/mail/BriefPane.tsx +4 -2
  6. package/src/components/mail/DailyBrief.selection.test.ts +49 -0
  7. package/src/components/mail/DailyBrief.tsx +484 -76
  8. package/src/components/mail/FlaggedList.tsx +3 -2
  9. package/src/components/mail/FlaggedPane.tsx +4 -2
  10. package/src/components/mail/MailListHeader.tsx +155 -34
  11. package/src/components/mail/MailViewChrome.tsx +18 -1
  12. package/src/components/mail/MailboxPane.tsx +55 -20
  13. package/src/components/mail/MessageCard.tsx +0 -1
  14. package/src/components/mail/MessageList.tsx +70 -15
  15. package/src/components/mail/MessageListItem.tsx +4 -21
  16. package/src/components/mail/MessageRow.tsx +13 -31
  17. package/src/components/mail/SwipeableMessageRow.tsx +13 -6
  18. package/src/components/mail/ThreadListInteraction.tsx +51 -6
  19. package/src/components/mail/organize/OrganizeRuleEditor.tsx +57 -2
  20. package/src/components/mail/organize/SearchFilterDialog.render.test.ts +13 -2
  21. package/src/components/mail/organize/SearchFilterDialog.tsx +3 -1
  22. package/src/components/mail/organize/SearchFilterEditor.render.test.ts +37 -3
  23. package/src/components/mail/organize/SearchFilterEditor.tsx +8 -2
  24. package/src/components/mail/organize/smart-organize.stories.tsx +86 -5
  25. package/src/components/mail/useModifierSelect.render.test.ts +247 -0
  26. package/src/components/mail/useModifierSelect.ts +109 -0
  27. package/src/components/onboarding/OnboardingWizard.tsx +53 -9
  28. package/src/components/settings/AccountFormPanel.tsx +10 -5
  29. package/src/hooks/bulk-chunking.render.test.ts +157 -0
  30. package/src/hooks/useApplyLabel.ts +6 -3
  31. package/src/hooks/useClauseSuggestions.ts +57 -0
  32. package/src/hooks/useDeleteMessages.ts +6 -3
  33. package/src/hooks/useFollowFocusOpen.render.test.ts +155 -0
  34. package/src/hooks/useFollowFocusOpen.ts +81 -0
  35. package/src/hooks/useInitialSyncProgress.render.test.ts +280 -0
  36. package/src/hooks/useInitialSyncProgress.ts +134 -0
  37. package/src/hooks/useListCursor.ts +36 -5
  38. package/src/hooks/useMarkAsRead.ts +6 -3
  39. package/src/hooks/useMoveMessages.ts +6 -3
  40. package/src/hooks/useRulePreview.ts +11 -1
  41. package/src/hooks/useSearchFilterSeed.render.test.ts +50 -15
  42. package/src/hooks/useSearchFilterSeed.ts +21 -3
  43. package/src/hooks/useSearchSuggestions.ts +126 -0
  44. package/src/hooks/useSelectedSubjects.ts +0 -0
  45. package/src/hooks/useSemanticSearch.ts +26 -7
  46. package/src/hooks/useThreadActions.ts +11 -2
  47. package/src/lib/brief.test.ts +67 -0
  48. package/src/lib/brief.ts +11 -1
  49. package/src/lib/bulk-actions.ts +29 -0
  50. package/src/lib/drafts.ts +1 -1
  51. package/src/lib/organize/clause-suggestions.test.ts +113 -0
  52. package/src/lib/organize/clause-suggestions.ts +90 -0
  53. package/src/lib/organize/property-prefill.test.ts +173 -0
  54. package/src/lib/organize/property-prefill.ts +151 -0
  55. package/src/lib/organize/rule-model.test.ts +57 -0
  56. package/src/lib/organize/rule-model.ts +110 -31
  57. package/src/lib/organize/search-to-rule.ts +12 -4
  58. package/src/lib/organize/sender-fallback.ts +11 -1
  59. package/src/lib/search-result.ts +2 -0
  60. package/src/lib/search-suggestions.test.ts +249 -0
  61. package/src/lib/search-suggestions.ts +296 -0
  62. package/src/lib/search-token-index.test.ts +99 -0
  63. package/src/lib/search-token-index.ts +67 -1
  64. package/src/lib/search-tokens.test.ts +236 -0
  65. package/src/lib/search-tokens.ts +303 -36
  66. package/src/lib/thread-cache.ts +1 -1
  67. package/src/lib/thread-search-tokens.test.ts +193 -0
  68. package/src/lib/thread-search-tokens.ts +148 -0
  69. package/src/routes/onboarding.tsx +9 -4
@@ -0,0 +1,280 @@
1
+ /**
2
+ * "Still arriving" is a claim about the server, so it is read from the sync
3
+ * phase the IMAP worker writes — never guessed from an empty list. What the
4
+ * tests pin is when the hook is allowed to speak at all: it stays unresolved
5
+ * until every account has answered, an unreachable account counts as answered
6
+ * rather than holding the list in limbo, and a phase it does not recognise is
7
+ * not syncing.
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import { afterEach, describe, it } from "node:test";
12
+ import type {
13
+ RemitImapAccountSyncStatusResponse,
14
+ RemitImapSyncPhase,
15
+ } from "@remit/api-http-client/types.gen.ts";
16
+ import { createElement } from "react";
17
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
18
+ import { type HttpMock, httpError, mockFetch } from "../test-support/http";
19
+ import {
20
+ ANSWER_DEADLINE_MS,
21
+ type InitialSyncProgress,
22
+ isSyncingPhase,
23
+ POLL_MS,
24
+ useInitialSyncProgress,
25
+ } from "./useInitialSyncProgress";
26
+
27
+ let harness: DomHarness | undefined;
28
+ let http: HttpMock | undefined;
29
+
30
+ afterEach(() => {
31
+ harness?.close();
32
+ harness = undefined;
33
+ http?.restore();
34
+ http = undefined;
35
+ });
36
+
37
+ interface MailboxProgress {
38
+ synced: number;
39
+ total: number;
40
+ }
41
+
42
+ const status = (
43
+ accountId: string,
44
+ syncPhase: RemitImapSyncPhase | undefined,
45
+ mailboxes: MailboxProgress[] = [],
46
+ ): RemitImapAccountSyncStatusResponse => ({
47
+ accountId,
48
+ ...(syncPhase ? { syncPhase } : {}),
49
+ mailboxes: mailboxes.map((mailbox, index) => ({
50
+ mailboxId: `mbx-${accountId}-${index}`,
51
+ fullPath: index === 0 ? "INBOX" : `Folder ${index}`,
52
+ phase: "syncing",
53
+ messagesSynced: mailbox.synced,
54
+ messagesTotal: mailbox.total,
55
+ })),
56
+ });
57
+
58
+ function Probe({
59
+ accountIds,
60
+ enabled,
61
+ }: {
62
+ accountIds: string[];
63
+ enabled: boolean;
64
+ }) {
65
+ const progress = useInitialSyncProgress(accountIds, enabled);
66
+ return createElement("div", null, JSON.stringify(progress));
67
+ }
68
+
69
+ /** The account id in `/accounts/{id}/sync/status`. */
70
+ const accountOf = (path: string): string => path.split("/")[2] ?? "";
71
+
72
+ const mount = async (
73
+ accountIds: string[],
74
+ answer: (accountId: string) => unknown,
75
+ enabled = true,
76
+ ): Promise<DomHarness> => {
77
+ http = mockFetch((call) => answer(accountOf(call.path)));
78
+ harness = createDomHarness();
79
+ harness.renderApp(createElement(Probe, { accountIds, enabled }));
80
+ await harness.flush();
81
+ await harness.flush();
82
+ return harness;
83
+ };
84
+
85
+ const progress = (dom: DomHarness): InitialSyncProgress =>
86
+ JSON.parse(dom.text());
87
+
88
+ describe("isSyncingPhase", () => {
89
+ it("is true only for the phases a running sync round writes", () => {
90
+ assert.equal(isSyncingPhase("discovering_mailboxes"), true);
91
+ assert.equal(isSyncingPhase("syncing_inbox"), true);
92
+ assert.equal(isSyncingPhase("syncing_others"), true);
93
+ });
94
+
95
+ it("is false for idle, complete, and error", () => {
96
+ assert.equal(isSyncingPhase("idle"), false);
97
+ assert.equal(isSyncingPhase("complete"), false);
98
+ assert.equal(isSyncingPhase("error"), false);
99
+ });
100
+
101
+ it("is false for an account row written before the phase existed", () => {
102
+ // "Not complete" would read this as syncing forever.
103
+ assert.equal(isSyncingPhase(undefined), false);
104
+ });
105
+ });
106
+
107
+ describe("useInitialSyncProgress", () => {
108
+ it("reports syncing with the counts of the accounts still syncing", async () => {
109
+ const dom = await mount(["acc-1"], (accountId) =>
110
+ status(accountId, "syncing_inbox", [
111
+ { synced: 40, total: 100 },
112
+ { synced: 10, total: 60 },
113
+ ]),
114
+ );
115
+ assert.deepEqual(progress(dom), {
116
+ syncing: true,
117
+ resolved: true,
118
+ synced: 50,
119
+ total: 160,
120
+ });
121
+ });
122
+
123
+ it("leaves out the counts of an account that has finished", async () => {
124
+ const dom = await mount(["acc-1", "acc-2"], (accountId) =>
125
+ accountId === "acc-1"
126
+ ? status(accountId, "syncing_inbox", [{ synced: 40, total: 100 }])
127
+ : status(accountId, "complete", [{ synced: 900, total: 900 }]),
128
+ );
129
+ assert.deepEqual(progress(dom), {
130
+ syncing: true,
131
+ resolved: true,
132
+ synced: 40,
133
+ total: 100,
134
+ });
135
+ });
136
+
137
+ it("resolves to not-syncing once every account is done", async () => {
138
+ const dom = await mount(["acc-1", "acc-2"], (accountId) =>
139
+ status(accountId, "complete", [{ synced: 900, total: 900 }]),
140
+ );
141
+ assert.deepEqual(progress(dom), {
142
+ syncing: false,
143
+ resolved: true,
144
+ synced: 0,
145
+ total: 0,
146
+ });
147
+ });
148
+
149
+ it("counts an unreachable account as answered rather than holding the list in limbo", async () => {
150
+ const dom = await mount(["acc-1", "acc-2"], (accountId) =>
151
+ accountId === "acc-1"
152
+ ? status(accountId, "syncing_inbox", [{ synced: 5, total: 50 }])
153
+ : httpError(503),
154
+ );
155
+ const state = progress(dom);
156
+ assert.equal(state.resolved, true);
157
+ assert.equal(state.syncing, true);
158
+ assert.equal(state.synced, 5);
159
+ });
160
+
161
+ it("knows nothing until every account has answered", async () => {
162
+ // One account answers, the other never does — the hook must not report on
163
+ // the half it has.
164
+ const original = globalThis.fetch;
165
+ try {
166
+ globalThis.fetch = ((input: RequestInfo | URL) => {
167
+ const url = input instanceof Request ? input.url : String(input);
168
+ if (url.includes("acc-2")) return new Promise<Response>(() => {});
169
+ return Promise.resolve(
170
+ new Response(
171
+ JSON.stringify(
172
+ status("acc-1", "syncing_inbox", [{ synced: 5, total: 50 }]),
173
+ ),
174
+ { status: 200, headers: { "content-type": "application/json" } },
175
+ ),
176
+ );
177
+ }) as typeof globalThis.fetch;
178
+ harness = createDomHarness();
179
+ harness.renderApp(
180
+ createElement(Probe, { accountIds: ["acc-1", "acc-2"], enabled: true }),
181
+ );
182
+ await harness.flush();
183
+ await harness.flush();
184
+ assert.deepEqual(progress(harness), {
185
+ syncing: false,
186
+ resolved: false,
187
+ synced: 0,
188
+ total: 0,
189
+ });
190
+ } finally {
191
+ globalThis.fetch = original;
192
+ }
193
+ });
194
+
195
+ it("answers immediately, and never syncing, for no accounts at all", async () => {
196
+ const dom = await mount([], () => ({}));
197
+ assert.deepEqual(progress(dom), {
198
+ syncing: false,
199
+ resolved: true,
200
+ synced: 0,
201
+ total: 0,
202
+ });
203
+ assert.deepEqual(http?.calls ?? [], []);
204
+ });
205
+
206
+ it("stops asking once every account has answered and none is syncing", async () => {
207
+ // The caught-up user's answer cannot change by being asked again.
208
+ const dom = await mount(["acc-1", "acc-2"], (accountId) =>
209
+ status(accountId, "complete"),
210
+ );
211
+ const asked = http?.calls.length ?? 0;
212
+ assert.equal(asked, 2);
213
+
214
+ await dom.wait(POLL_MS + 500);
215
+
216
+ assert.equal(http?.calls.length, asked);
217
+ assert.equal(progress(dom).resolved, true);
218
+ });
219
+
220
+ it("keeps asking while an account is still syncing", async () => {
221
+ const dom = await mount(["acc-1"], (accountId) =>
222
+ status(accountId, "syncing_inbox", [{ synced: 5, total: 50 }]),
223
+ );
224
+
225
+ await dom.wait(POLL_MS + 500);
226
+
227
+ assert.ok(
228
+ (http?.calls.length ?? 0) > 1,
229
+ "progress has to keep arriving while the sync runs",
230
+ );
231
+ });
232
+
233
+ it("answers without an account that never responds", async () => {
234
+ const original = globalThis.fetch;
235
+ try {
236
+ globalThis.fetch = ((input: RequestInfo | URL) => {
237
+ const url = input instanceof Request ? input.url : String(input);
238
+ if (url.includes("acc-2")) return new Promise<Response>(() => {});
239
+ return Promise.resolve(
240
+ new Response(JSON.stringify(status("acc-1", "complete")), {
241
+ status: 200,
242
+ headers: { "content-type": "application/json" },
243
+ }),
244
+ );
245
+ }) as typeof globalThis.fetch;
246
+ harness = createDomHarness();
247
+ harness.renderApp(
248
+ createElement(Probe, { accountIds: ["acc-1", "acc-2"], enabled: true }),
249
+ );
250
+ await harness.flush();
251
+ assert.equal(progress(harness).resolved, false);
252
+
253
+ await harness.wait(ANSWER_DEADLINE_MS + 500);
254
+
255
+ assert.deepEqual(progress(harness), {
256
+ syncing: false,
257
+ resolved: true,
258
+ synced: 0,
259
+ total: 0,
260
+ });
261
+ } finally {
262
+ globalThis.fetch = original;
263
+ }
264
+ });
265
+
266
+ it("asks nothing and claims nothing while disabled", async () => {
267
+ const dom = await mount(
268
+ ["acc-1"],
269
+ () => status("acc-1", "syncing_inbox"),
270
+ false,
271
+ );
272
+ assert.deepEqual(progress(dom), {
273
+ syncing: false,
274
+ resolved: false,
275
+ synced: 0,
276
+ total: 0,
277
+ });
278
+ assert.deepEqual(http?.calls ?? [], []);
279
+ });
280
+ });
@@ -0,0 +1,134 @@
1
+ import { syncOperationsGetSyncStatusOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { RemitImapSyncPhase } from "@remit/api-http-client/types.gen.ts";
3
+ import { useQueries } from "@tanstack/react-query";
4
+ import { useEffect, useState } from "react";
5
+
6
+ /**
7
+ * The phases the server writes while a sync round is actually running.
8
+ *
9
+ * A set of the in-progress values rather than "not complete": an account row
10
+ * written before the phase existed reads back `undefined`, and `idle` means no
11
+ * sync is running. Both would otherwise pin a list to "still syncing" forever,
12
+ * which is the same dishonesty as the bug in the other direction.
13
+ */
14
+ const IN_PROGRESS: ReadonlySet<RemitImapSyncPhase> = new Set([
15
+ "discovering_mailboxes",
16
+ "syncing_inbox",
17
+ "syncing_others",
18
+ ]);
19
+
20
+ /** Whether a phase reading means the server is mid-sync right now. */
21
+ export function isSyncingPhase(phase: RemitImapSyncPhase | undefined): boolean {
22
+ return !!phase && IN_PROGRESS.has(phase);
23
+ }
24
+
25
+ export const POLL_MS = 3000;
26
+
27
+ /**
28
+ * How long an account may stay silent before the answer is given without it.
29
+ *
30
+ * An account that errors counts as answered; one that never responds does not,
31
+ * and without a bound a single hung account holds the brief on its skeleton for
32
+ * as long as the tab is open — it would never say "You're caught up". Two poll
33
+ * rounds is long enough that a slow-but-alive account still gets to speak.
34
+ */
35
+ export const ANSWER_DEADLINE_MS = POLL_MS * 2;
36
+
37
+ export interface InitialSyncProgress {
38
+ /** At least one account reports an in-progress sync phase. */
39
+ syncing: boolean;
40
+ /** Every enabled sync-status query has answered; until then nothing is known. */
41
+ resolved: boolean;
42
+ /** Messages downloaded so far, summed over the accounts still syncing. */
43
+ synced: number;
44
+ /** What those accounts hold in total; 0 while the server has not counted. */
45
+ total: number;
46
+ }
47
+
48
+ const UNKNOWN: InitialSyncProgress = {
49
+ syncing: false,
50
+ resolved: false,
51
+ synced: 0,
52
+ total: 0,
53
+ };
54
+
55
+ /**
56
+ * Whether any of these accounts is mid-sync, read from the account sync-status
57
+ * endpoint (`syncPhase`) — a real server-side state the IMAP worker writes, not
58
+ * a guess from message counts. The per-mailbox message numbers alongside it are
59
+ * approximate by the API's own definition (a UID-range estimate), so they are
60
+ * only ever shown as progress, never as a total the UI reasons about.
61
+ *
62
+ * `enabled` exists because the answer only matters when something is about to
63
+ * be claimed about an empty result; polling every account continuously to
64
+ * answer a question nobody asked is not worth the requests.
65
+ *
66
+ * The poll is a question with a last answer. Once every account has spoken and
67
+ * none is mid-sync, nothing further can change the reading, so the polling
68
+ * stops there rather than re-asking every three seconds for the rest of the
69
+ * session — the caught-up user was paying about 1,200 requests an hour per
70
+ * account for an answer that was already final. A new account set asks again.
71
+ */
72
+ export function useInitialSyncProgress(
73
+ accountIds: string[],
74
+ enabled: boolean,
75
+ ): InitialSyncProgress {
76
+ const accountKey = accountIds.join(",");
77
+ const [settled, setSettled] = useState(false);
78
+ const [deadlinePassed, setDeadlinePassed] = useState(false);
79
+
80
+ // A different account set is a different question: it gets its own answer,
81
+ // its own deadline, and a poll that runs again until it has one.
82
+ // biome-ignore lint/correctness/useExhaustiveDependencies: accountKey/enabled are trigger-only — the reset is unconditional, not a value read from either.
83
+ useEffect(() => {
84
+ setSettled(false);
85
+ setDeadlinePassed(false);
86
+ }, [accountKey, enabled]);
87
+
88
+ // biome-ignore lint/correctness/useExhaustiveDependencies: accountKey restarts the deadline for a new question rather than being read inside it.
89
+ useEffect(() => {
90
+ if (!enabled) return;
91
+ const timer = setTimeout(() => setDeadlinePassed(true), ANSWER_DEADLINE_MS);
92
+ return () => clearTimeout(timer);
93
+ }, [accountKey, enabled]);
94
+
95
+ const queries = useQueries({
96
+ queries: accountIds.map((accountId) => ({
97
+ ...syncOperationsGetSyncStatusOptions({ path: { accountId } }),
98
+ // Disabling keeps the cached answer and its success/error status; it
99
+ // only stops the interval.
100
+ enabled: enabled && !settled,
101
+ refetchInterval: POLL_MS,
102
+ // A failed sync-status read is not the account failing — it must not
103
+ // raise the global fatal overlay over a question about an empty list.
104
+ meta: { softError: true },
105
+ })),
106
+ });
107
+
108
+ // A query that errored still counts as answered: the caller's fallback is the
109
+ // same either way, and one unreachable account must not hold the whole list
110
+ // in limbo. One that never answers at all is covered by the deadline instead.
111
+ const answered = queries.every((q) => q.isSuccess || q.isError);
112
+ const resolved = queries.length === 0 || answered || deadlinePassed;
113
+
114
+ let syncing = false;
115
+ let synced = 0;
116
+ let total = 0;
117
+ for (const query of queries) {
118
+ if (!isSyncingPhase(query.data?.syncPhase)) continue;
119
+ syncing = true;
120
+ for (const mailbox of query.data?.mailboxes ?? []) {
121
+ synced += mailbox.messagesSynced;
122
+ total += mailbox.messagesTotal;
123
+ }
124
+ }
125
+
126
+ const final = enabled && resolved && !syncing;
127
+ useEffect(() => {
128
+ if (final) setSettled(true);
129
+ }, [final]);
130
+
131
+ if (!enabled) return UNKNOWN;
132
+ if (!resolved) return UNKNOWN;
133
+ return { syncing, resolved: true, synced, total };
134
+ }
@@ -34,6 +34,13 @@ interface UseListCursorOptions {
34
34
  export interface ListCursor {
35
35
  focusedMessageId: string | undefined;
36
36
  setFocusedMessageId: (id: string | undefined) => void;
37
+ /**
38
+ * The row a keyboard command last moved the cursor onto, and `undefined`
39
+ * whenever the cursor last moved some other way. Drives the reading pane
40
+ * following the cursor (`useFollowFocusOpen`) — a click and Enter open on
41
+ * their own, so only a bare cursor move is left to follow.
42
+ */
43
+ keyboardFocusedMessageId: string | undefined;
37
44
  focusIndex: number;
38
45
  pendingDomFocusRef: React.RefObject<string | null>;
39
46
  cursorMovedByPointerRef: React.RefObject<boolean>;
@@ -67,13 +74,32 @@ export const useListCursor = ({
67
74
  onExitSelection,
68
75
  }: UseListCursorOptions): ListCursor => {
69
76
  // The keyboard "where am I" pointer, distinct from the open thread
70
- // (`selectedMessageId` in the URL). j/k move this cursor without opening;
71
- // Enter opens the focused row. It seeds from the open thread so opening a
77
+ // (`selectedMessageId` in the URL). j/k move this cursor; Enter opens the
78
+ // focused row, and on desktop the reading pane follows the cursor of its own
79
+ // accord (`useFollowFocusOpen`). It seeds from the open thread so opening a
72
80
  // message also focuses its row.
73
- const [focusedMessageId, setFocusedMessageId] = useState<string | undefined>(
81
+ const [focusedMessageId, setFocusedId] = useState<string | undefined>(
74
82
  initialFocusedId,
75
83
  );
76
84
 
85
+ // Which of those moves came from a keyboard command, so the reading pane can
86
+ // follow the cursor without following a click that already opened its own row.
87
+ const [keyboardFocusedMessageId, setKeyboardFocusedMessageId] = useState<
88
+ string | undefined
89
+ >();
90
+
91
+ // Every non-keyboard move — a click, Tab, a thread opening, a refetch snapping
92
+ // the cursor to a survivor — drops the keyboard mark, so nothing follows it.
93
+ // A row taking DOM focus as the *consequence* of a keyboard move arrives here
94
+ // with the id that move just set; keeping the mark in that case is what stops
95
+ // the browser's own focus event from cancelling the load the move started.
96
+ const setFocusedMessageId = useCallback((id: string | undefined) => {
97
+ setKeyboardFocusedMessageId((current) =>
98
+ current === id ? current : undefined,
99
+ );
100
+ setFocusedId(id);
101
+ }, []);
102
+
77
103
  const selection = useSelection();
78
104
  const {
79
105
  selectedCount,
@@ -122,7 +148,8 @@ export const useListCursor = ({
122
148
  }
123
149
  pendingDomFocusRef.current = messageId;
124
150
  cursorMovedByPointerRef.current = false;
125
- setFocusedMessageId(messageId);
151
+ setKeyboardFocusedMessageId(messageId);
152
+ setFocusedId(messageId);
126
153
  },
127
154
  [orderedIds, isMultiSelectMode, toggleCheck],
128
155
  );
@@ -191,7 +218,10 @@ export const useListCursor = ({
191
218
  selectRange(orderedIds, target);
192
219
  pendingDomFocusRef.current = target;
193
220
  cursorMovedByPointerRef.current = false;
194
- setFocusedMessageId(target);
221
+ // Shift+arrow is building a range, not reading. The reading pane stays on
222
+ // whatever is open rather than chasing the growing edge of the selection.
223
+ setKeyboardFocusedMessageId(undefined);
224
+ setFocusedId(target);
195
225
  },
196
226
  [orderedIds, focusedMessageId, selectRange],
197
227
  );
@@ -206,6 +236,7 @@ export const useListCursor = ({
206
236
  return {
207
237
  focusedMessageId,
208
238
  setFocusedMessageId,
239
+ keyboardFocusedMessageId,
209
240
  focusIndex,
210
241
  pendingDomFocusRef,
211
242
  cursorMovedByPointerRef,
@@ -8,6 +8,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
8
8
  import { useCallback, useEffect, useMemo, useRef } from "react";
9
9
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
10
10
  import { formatErrorDetail } from "@/components/ui/error-banners";
11
+ import { runChunkedMutation } from "@/lib/bulk-actions";
11
12
  import {
12
13
  cancelThreadListQueries,
13
14
  invalidateThreadListQueries,
@@ -298,7 +299,7 @@ export const useToggleReadFor = (options: {
298
299
  const queryClient = useQueryClient();
299
300
  const { pushError } = useErrorBanners();
300
301
 
301
- const { mutate, isPending } = useMutation({
302
+ const { mutateAsync, isPending } = useMutation({
302
303
  ...messageBulkOperationsUpdateFlagsMutation(),
303
304
  onError: (error, variables) => {
304
305
  const isRead = variables.body.isRead ?? true;
@@ -332,9 +333,11 @@ export const useToggleReadFor = (options: {
332
333
  const toggleReadFor = useCallback(
333
334
  (messageIds: string[], isRead: boolean) => {
334
335
  if (messageIds.length === 0) return;
335
- mutate({ body: { messageIds, isRead } });
336
+ void runChunkedMutation(messageIds, (chunk) =>
337
+ mutateAsync({ body: { messageIds: chunk, isRead } }),
338
+ );
336
339
  },
337
- [mutate],
340
+ [mutateAsync],
338
341
  );
339
342
 
340
343
  return { toggleReadFor, isPending };
@@ -8,6 +8,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
8
8
  import { useCallback } from "react";
9
9
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
10
10
  import { formatErrorDetail } from "@/components/ui/error-banners";
11
+ import { runChunkedMutation } from "@/lib/bulk-actions";
11
12
  import {
12
13
  cancelThreadListQueries,
13
14
  invalidateThreadListQueries,
@@ -70,7 +71,7 @@ export const useMoveMessages = ({
70
71
  const queryClient = useQueryClient();
71
72
  const { pushError } = useErrorBanners();
72
73
 
73
- const { mutate, isPending } = useMutation({
74
+ const { mutateAsync, isPending } = useMutation({
74
75
  ...messageBulkOperationsMoveMessagesMutation(),
75
76
  onMutate: async (variables): Promise<MoveContext> => {
76
77
  const messageIds = new Set(variables.body.messageIds ?? []);
@@ -181,9 +182,11 @@ export const useMoveMessages = ({
181
182
  const moveMessages = useCallback(
182
183
  (messageIds: string[], destinationMailboxId: string) => {
183
184
  if (messageIds.length === 0) return;
184
- mutate({ body: { messageIds, destinationMailboxId } });
185
+ void runChunkedMutation(messageIds, (chunk) =>
186
+ mutateAsync({ body: { messageIds: chunk, destinationMailboxId } }),
187
+ );
185
188
  },
186
- [mutate],
189
+ [mutateAsync],
187
190
  );
188
191
 
189
192
  return { moveMessages, isPending };
@@ -5,9 +5,11 @@ import { useEffect, useRef, useState } from "react";
5
5
  import { buildOrganizeInput } from "@/lib/organize/organize-model";
6
6
  import {
7
7
  derivePreview,
8
+ isEvaluablePredicate,
8
9
  PREVIEW_DEBOUNCE_MS,
9
10
  type PreviewState,
10
11
  predicateSignature,
12
+ UNCOUNTABLE_PREDICATE_REASON,
11
13
  } from "@/lib/organize/rule-model";
12
14
  import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
13
15
 
@@ -45,9 +47,13 @@ export const useRulePreview = (
45
47
  const predicateRef = useRef(predicate);
46
48
  predicateRef.current = predicate;
47
49
  const latestRequested = useRef(currentSignature);
50
+ // A predicate the matcher rejects outright is never sent — the request would
51
+ // 500, and a 500 is not a count. The editor says so instead and holds the
52
+ // commit for the scopes that would run this same matcher.
53
+ const countable = isEvaluablePredicate(predicate);
48
54
 
49
55
  useEffect(() => {
50
- if (!accountId) return;
56
+ if (!accountId || !countable) return;
51
57
  if (currentSignature === state.previewedSignature) return;
52
58
  if (currentSignature === state.errorSignature) return;
53
59
 
@@ -81,11 +87,15 @@ export const useRulePreview = (
81
87
  return () => clearTimeout(handle);
82
88
  }, [
83
89
  accountId,
90
+ countable,
84
91
  currentSignature,
85
92
  state.previewedSignature,
86
93
  state.errorSignature,
87
94
  mutateAsync,
88
95
  ]);
89
96
 
97
+ if (!countable) {
98
+ return { status: "error", reason: UNCOUNTABLE_PREDICATE_REASON };
99
+ }
90
100
  return derivePreview(state, currentSignature);
91
101
  };
@@ -28,28 +28,43 @@ afterEach(() => {
28
28
  http = undefined;
29
29
  });
30
30
 
31
- const PREDICATE: OrganizeMatchPredicate = {
31
+ const COUNTABLE: OrganizeMatchPredicate = {
32
+ matchOperator: "And",
33
+ literalClauses: [{ field: "From", value: "receipts@shop.example" }],
34
+ };
35
+
36
+ /**
37
+ * A free-text search converts to a body-content clause, which the vector-free
38
+ * matcher refuses. The seed must not ask.
39
+ */
40
+ const UNCOUNTABLE: OrganizeMatchPredicate = {
32
41
  matchOperator: "And",
33
42
  literalClauses: [{ field: "HasWords", value: "receipts" }],
34
43
  };
35
44
 
36
- function Probe() {
37
- const seed = useSearchFilterSeed("acc-1", PREDICATE);
38
- return createElement(
39
- "div",
40
- null,
41
- JSON.stringify({
42
- seedCount: seed.seedCount ?? null,
43
- isPending: seed.isPending,
44
- isError: seed.isError,
45
- }),
46
- );
45
+ function probeFor(predicate: OrganizeMatchPredicate) {
46
+ return function Probe() {
47
+ const seed = useSearchFilterSeed("acc-1", predicate);
48
+ return createElement(
49
+ "div",
50
+ null,
51
+ JSON.stringify({
52
+ seedCount: seed.seedCount ?? null,
53
+ isPending: seed.isPending,
54
+ isError: seed.isError,
55
+ uncountable: seed.uncountable,
56
+ }),
57
+ );
58
+ };
47
59
  }
48
60
 
49
- const mount = (responder: (call: HttpCall) => unknown): DomHarness => {
61
+ const mount = (
62
+ responder: (call: HttpCall) => unknown,
63
+ predicate: OrganizeMatchPredicate = COUNTABLE,
64
+ ): DomHarness => {
50
65
  http = mockFetch(responder);
51
66
  harness = createDomHarness();
52
- harness.renderApp(createElement(Probe));
67
+ harness.renderApp(createElement(probeFor(predicate)));
53
68
  return harness;
54
69
  };
55
70
 
@@ -70,7 +85,7 @@ describe("useSearchFilterSeed", () => {
70
85
  assert.equal(previews.length, 1);
71
86
  assert.equal(previews[0].body?.anchorMessageId, undefined);
72
87
  assert.deepEqual(previews[0].body?.literalClauses, [
73
- { field: "HasWords", value: "receipts" },
88
+ { field: "From", value: "receipts@shop.example" },
74
89
  ]);
75
90
  });
76
91
 
@@ -80,4 +95,24 @@ describe("useSearchFilterSeed", () => {
80
95
  await dom.flush();
81
96
  assert.equal(state(dom).isError, true);
82
97
  });
98
+
99
+ it("never asks for a count the matcher cannot produce", async () => {
100
+ const dom = mount(
101
+ (call) =>
102
+ call.path.endsWith("/organize/preview")
103
+ ? { matchedCount: 12, messageIds: [] }
104
+ : {},
105
+ UNCOUNTABLE,
106
+ );
107
+ await dom.flush();
108
+ await dom.flush();
109
+ assert.equal(http?.to("/organize/preview").length, 0);
110
+ // Not pending and not an error — the editor opens, it just has no number.
111
+ assert.deepEqual(state(dom), {
112
+ seedCount: null,
113
+ isPending: false,
114
+ isError: false,
115
+ uncountable: true,
116
+ });
117
+ });
83
118
  });