@remit/web-client 0.0.86 → 0.0.88

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 (70) 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 +157 -38
  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 +5 -6
  22. package/src/components/mail/organize/SearchFilterEditor.render.test.ts +37 -3
  23. package/src/components/mail/organize/SearchFilterEditor.tsx +10 -6
  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/useOrganizeWiden.ts +1 -2
  41. package/src/hooks/useRulePreview.ts +11 -1
  42. package/src/hooks/useSearchFilterSeed.render.test.ts +50 -15
  43. package/src/hooks/useSearchFilterSeed.ts +21 -3
  44. package/src/hooks/useSearchSuggestions.ts +126 -0
  45. package/src/hooks/useSelectedSubjects.ts +0 -0
  46. package/src/hooks/useSemanticSearch.ts +26 -7
  47. package/src/hooks/useThreadActions.ts +11 -2
  48. package/src/lib/brief.test.ts +67 -0
  49. package/src/lib/brief.ts +11 -1
  50. package/src/lib/bulk-actions.ts +29 -0
  51. package/src/lib/drafts.ts +1 -1
  52. package/src/lib/organize/clause-suggestions.test.ts +113 -0
  53. package/src/lib/organize/clause-suggestions.ts +89 -0
  54. package/src/lib/organize/rule-model.test.ts +57 -0
  55. package/src/lib/organize/rule-model.ts +117 -38
  56. package/src/lib/organize/search-to-rule.test.ts +8 -28
  57. package/src/lib/organize/search-to-rule.ts +33 -89
  58. package/src/lib/organize/sender-fallback.test.ts +1 -97
  59. package/src/lib/organize/sender-fallback.ts +8 -71
  60. package/src/lib/search-result.ts +2 -0
  61. package/src/lib/search-suggestions.test.ts +249 -0
  62. package/src/lib/search-suggestions.ts +296 -0
  63. package/src/lib/search-token-index.test.ts +99 -0
  64. package/src/lib/search-token-index.ts +67 -1
  65. package/src/lib/search-tokens.test.ts +236 -0
  66. package/src/lib/search-tokens.ts +303 -36
  67. package/src/lib/thread-cache.ts +1 -1
  68. package/src/lib/thread-search-tokens.test.ts +193 -0
  69. package/src/lib/thread-search-tokens.ts +148 -0
  70. package/src/routes/onboarding.tsx +9 -4
@@ -21,6 +21,7 @@ import {
21
21
  type EscalationSearchQuery,
22
22
  useEscalatedActions,
23
23
  } from "@/hooks/useEscalatedActions";
24
+ import { useFollowFocusOpen } from "@/hooks/useFollowFocusOpen";
24
25
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
25
26
  import { useIsDesktop } from "@/hooks/useMediaQuery";
26
27
  import {
@@ -245,13 +246,33 @@ export const MessageList = ({
245
246
 
246
247
  // Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
247
248
  // from the open thread (`selectedMessageId` in the URL). j/k move this
248
- // cursor without opening; Enter opens the focused row → sets selected. It
249
- // seeds from the open thread so opening a message also focuses its row, and
250
- // click-to-open keeps working unchanged (the route still navigates).
251
- const [focusedMessageId, setFocusedMessageId] = useState<string | undefined>(
249
+ // cursor; Enter opens the focused row → sets selected, and on desktop the
250
+ // reading pane follows the cursor of its own accord (see the follow-focus
251
+ // wiring below). It seeds from the open thread so opening a message also
252
+ // focuses its row, and click-to-open keeps working unchanged (the route
253
+ // still navigates).
254
+ const [focusedMessageId, setFocusedId] = useState<string | undefined>(
252
255
  selectedMessageId,
253
256
  );
254
257
 
258
+ // Which of those moves came from a keyboard command, so the reading pane can
259
+ // follow the cursor without following a click that already opened its own row.
260
+ const [keyboardFocusedMessageId, setKeyboardFocusedMessageId] = useState<
261
+ string | undefined
262
+ >();
263
+
264
+ // Every non-keyboard move — a click, Tab, a thread opening, a refetch snapping
265
+ // the cursor to a survivor — drops the keyboard mark, so nothing follows it.
266
+ // A row taking DOM focus as the *consequence* of a keyboard move arrives here
267
+ // with the id that move just set; keeping the mark in that case is what stops
268
+ // the browser's own focus event from cancelling the load the move started.
269
+ const setFocusedMessageId = useCallback((id: string | undefined) => {
270
+ setKeyboardFocusedMessageId((current) =>
271
+ current === id ? current : undefined,
272
+ );
273
+ setFocusedId(id);
274
+ }, []);
275
+
255
276
  // Density toggle: comfortable (default) or compact (mutt mode).
256
277
  // Persisted to localStorage so the choice survives reloads.
257
278
  const [density, setDensity] = useState<Density>(readStoredDensity);
@@ -448,7 +469,8 @@ export const MessageList = ({
448
469
  }
449
470
  pendingDomFocusRef.current = thread.messageId;
450
471
  cursorMovedByPointerRef.current = false;
451
- setFocusedMessageId(thread.messageId);
472
+ setKeyboardFocusedMessageId(thread.messageId);
473
+ setFocusedId(thread.messageId);
452
474
  },
453
475
  [threads, isMultiSelectMode, toggleCheck],
454
476
  );
@@ -564,10 +586,12 @@ export const MessageList = ({
564
586
  selectRange(orderedIds, target);
565
587
  // Shift+arrow moves the focus cursor (not the open thread) and grows
566
588
  // the selection from the anchor — the keyboard equivalent of
567
- // shift-click.
589
+ // shift-click. It is building a range, not reading, so the reading pane
590
+ // stays on whatever is open rather than chasing the range's edge.
568
591
  pendingDomFocusRef.current = target;
569
592
  cursorMovedByPointerRef.current = false;
570
- setFocusedMessageId(target);
593
+ setKeyboardFocusedMessageId(undefined);
594
+ setFocusedId(target);
571
595
  },
572
596
  [orderedIds, focusedMessageId, selectRange],
573
597
  );
@@ -621,6 +645,34 @@ export const MessageList = ({
621
645
  });
622
646
  }, [focusedMessageId, navigate, mailboxId]);
623
647
 
648
+ // The reading pane follows the cursor on desktop: j/k load the row they land
649
+ // on, without the user having to press Enter for every message. It replaces
650
+ // the history entry rather than pushing one — a preview the cursor produced
651
+ // is not a navigation the user asked for, and Back should still leave the
652
+ // mailbox instead of walking the cursor's path back up the list.
653
+ //
654
+ // Suspended while rows are selected: the cursor is then picking out a set,
655
+ // and the selection toolbar — not a message — is what the user is looking at.
656
+ // Suspended off desktop too, where the reading pane replaces the list
657
+ // entirely, so following the cursor would throw the user off the list.
658
+ const followFocusOpen = useCallback(
659
+ (messageId: string) => {
660
+ navigate({
661
+ to: "/mail/$mailboxId",
662
+ params: { mailboxId },
663
+ search: (prev) => ({ ...prev, selectedMessageId: messageId }),
664
+ replace: true,
665
+ });
666
+ },
667
+ [navigate, mailboxId],
668
+ );
669
+ useFollowFocusOpen({
670
+ keyboardFocusedMessageId,
671
+ openMessageId: selectedMessageId,
672
+ enabled: isDesktop && selectedCount === 0,
673
+ open: followFocusOpen,
674
+ });
675
+
624
676
  // Toolbar Trash2: confirm-delete the current selection.
625
677
  const handleDelete = useCallback(() => {
626
678
  if (selectedCount > 0) {
@@ -742,6 +794,7 @@ export const MessageList = ({
742
794
  mailboxId,
743
795
  isDesktop,
744
796
  runChunkedConfirmDelete,
797
+ setFocusedMessageId,
745
798
  ]);
746
799
 
747
800
  // Every way out of the confirmation that isn't the delete — Escape, Cancel,
@@ -756,7 +809,7 @@ export const MessageList = ({
756
809
  pendingDomFocusRef.current = restoreTo;
757
810
  cursorMovedByPointerRef.current = false;
758
811
  setFocusedMessageId(restoreTo);
759
- }, []);
812
+ }, [setFocusedMessageId]);
760
813
 
761
814
  // Mark read, bounded and escalated alike (#114). Both go through the same
762
815
  // chunked run: the bulk flags call caps at 100 ids, so a selection past
@@ -925,7 +978,7 @@ export const MessageList = ({
925
978
  cursorMovedByPointerRef.current = false;
926
979
  setFocusedMessageId(selectedMessageId);
927
980
  }
928
- }, [selectedMessageId]);
981
+ }, [selectedMessageId, setFocusedMessageId]);
929
982
 
930
983
  // Keep the focus cursor valid as the thread list changes (after delete /
931
984
  // move / refetch). If the focused row vanished, snap to the nearest
@@ -934,7 +987,7 @@ export const MessageList = ({
934
987
  if (!focusedMessageId) return;
935
988
  if (threads.some((t) => t.messageId === focusedMessageId)) return;
936
989
  setFocusedMessageId(threads[0]?.messageId);
937
- }, [threads, focusedMessageId]);
990
+ }, [threads, focusedMessageId, setFocusedMessageId]);
938
991
 
939
992
  // Bridge the roving cursor + selection up to the route's global keyboard
940
993
  // dispatcher (#429) so the action verbs can target the focused row, or the
@@ -1364,10 +1417,13 @@ export const MessageList = ({
1364
1417
 
1365
1418
  // A row focused by Tab or click becomes the cursor, so the keys act on what
1366
1419
  // the browser says is focused.
1367
- const handleRowFocus = useCallback((messageId: string) => {
1368
- cursorMovedByPointerRef.current = true;
1369
- setFocusedMessageId(messageId);
1370
- }, []);
1420
+ const handleRowFocus = useCallback(
1421
+ (messageId: string) => {
1422
+ cursorMovedByPointerRef.current = true;
1423
+ setFocusedMessageId(messageId);
1424
+ },
1425
+ [setFocusedMessageId],
1426
+ );
1371
1427
 
1372
1428
  // The virtualized list body: rows + search header + load-more indicator.
1373
1429
  // Passed to MessageListPane as `listBody` so the kit provides the chrome
@@ -1421,7 +1477,6 @@ export const MessageList = ({
1421
1477
  <SwipeableMessageRow
1422
1478
  thread={thread}
1423
1479
  mailboxId={mailboxId}
1424
- accountId={accountId}
1425
1480
  isSelected={selectedMessageId === thread.messageId}
1426
1481
  isFocused={focusedMessageId === thread.messageId}
1427
1482
  isTabStop={tabStopMessageId === thread.messageId}
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * MessageListItem — the mailbox list's adapter onto the shared `MessageRow`.
3
3
  *
4
- * It maps an API thread to the row's `ThreadRowData` shape and supplies the two
5
- * things only the mailbox list has: route-linking rows and the auto-moved
6
- * badge. Everything visual and interactive lives in `MessageRow`, which the
7
- * brief and Flagged render too.
4
+ * It maps an API thread to the row's `ThreadRowData` shape and supplies the one
5
+ * thing only the mailbox list has: route-linking rows. Everything visual and
6
+ * interactive lives in `MessageRow`, which the brief and Flagged render too.
8
7
  */
9
8
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
10
9
  import type { Density, SenderTrustLevel, ThreadRowData } from "@remit/ui";
@@ -12,14 +11,11 @@ import { memo } from "react";
12
11
  import type { SelectionModifiers } from "@/hooks/useSelection";
13
12
  import { toDisplayCategory } from "@/lib/display-category";
14
13
  import { formatEmailDate } from "@/lib/format";
15
- import { AutoMovedIndicator } from "./AutoMovedIndicator";
16
14
  import { MessageRow } from "./MessageRow";
17
15
 
18
16
  interface MessageListItemProps {
19
17
  thread: RemitImapThreadMessageResponse;
20
18
  mailboxId: string;
21
- /** Owning account, used to resolve the Inbox/Junk mailboxes for the auto-moved badge's undo action. */
22
- accountId?: string;
23
19
  isSelected: boolean;
24
20
  isFocused?: boolean;
25
21
  isTabStop?: boolean;
@@ -43,7 +39,7 @@ export const threadToRowData = (
43
39
  messageCount?: number,
44
40
  ): ThreadRowData => ({
45
41
  id: thread.messageId,
46
- accountId: thread.accountConfigId,
42
+ accountId: thread.accountId,
47
43
  mailboxId: thread.mailboxId,
48
44
  fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
49
45
  fromEmail: thread.fromEmail ?? "",
@@ -66,7 +62,6 @@ export const threadToRowData = (
66
62
  const MessageListItemComponent = ({
67
63
  thread,
68
64
  mailboxId,
69
- accountId,
70
65
  isSelected,
71
66
  isFocused,
72
67
  isTabStop,
@@ -97,18 +92,6 @@ const MessageListItemComponent = ({
97
92
  isMultiSelectMode,
98
93
  onLongPress,
99
94
  }}
100
- badge={
101
- thread.autoMoved ? (
102
- <AutoMovedIndicator
103
- accountId={accountId}
104
- messageId={thread.messageId}
105
- threadId={thread.threadId}
106
- mailboxId={thread.mailboxId}
107
- autoMoved={thread.autoMoved}
108
- size="sm"
109
- />
110
- ) : undefined
111
- }
112
95
  />
113
96
  );
114
97
 
@@ -28,6 +28,7 @@ import { type MouseEvent, memo, type ReactNode, useCallback } from "react";
28
28
  import type { SelectionModifiers } from "@/hooks/useSelection";
29
29
  import { cn } from "@/lib/utils";
30
30
  import { useThreadRowInteraction } from "./ThreadListInteraction";
31
+ import { modifiersOf, useModifierSelect } from "./useModifierSelect";
31
32
 
32
33
  interface MailboxLinkSearch {
33
34
  selectedMessageId?: string;
@@ -125,21 +126,18 @@ const MessageRowComponent = ({
125
126
  [onToggleCheck, messageId],
126
127
  );
127
128
 
128
- // Desktop mouse selection semantics. Plain click falls through to the Link's
129
- // navigation (or `onClick` on a non-linking row); shift / cmd / ctrl click is
130
- // routed to selection and the navigation is suppressed.
131
- //
132
- // A modified click must preventDefault: the router skips navigation for any
133
- // modified click and leaves the anchor's own default in place, which in a
134
- // browser means shift-click opens a new window and cmd-click a new tab.
135
- // Shift-click also drags a native text selection across the rows it spans,
136
- // so drop it — the row highlight is the selection the user asked for.
129
+ const modifierSelect = useModifierSelect(messageId, onRowSelect);
130
+
131
+ // A plain click falls through to the Link's navigation (or `onClick` on a
132
+ // non-linking row). A modified click belongs to selection and is consumed by
133
+ // `claimClick`, whichever of the press or the click the engine delivered.
137
134
  //
138
135
  // On mobile, once selection mode is active (#92) a plain tap toggles the row
139
136
  // instead of opening it — the same tap-to-toggle contract every reference
140
137
  // mail client uses once you're mid-selection.
141
138
  const handleRowClick = useCallback(
142
139
  (e: MouseEvent) => {
140
+ if (modifierSelect.claimClick(e)) return;
143
141
  if (!isDesktop) {
144
142
  if (isMultiSelectMode) {
145
143
  e.preventDefault();
@@ -150,20 +148,13 @@ const MessageRowComponent = ({
150
148
  onClick?.();
151
149
  return;
152
150
  }
153
- const modifiers = {
154
- shiftKey: e.shiftKey,
155
- metaKey: e.metaKey,
156
- ctrlKey: e.ctrlKey,
157
- };
158
- if (onRowSelect?.(messageId, modifiers)) {
159
- e.preventDefault();
160
- e.stopPropagation();
161
- if (modifiers.shiftKey) window.getSelection()?.removeAllRanges();
162
- return;
163
- }
151
+ // A plain desktop click collapses any multi-selection and re-anchors it
152
+ // before the row opens.
153
+ onRowSelect?.(messageId, modifiersOf(e));
164
154
  onClick?.();
165
155
  },
166
156
  [
157
+ modifierSelect,
167
158
  isDesktop,
168
159
  isMultiSelectMode,
169
160
  onToggleCheck,
@@ -173,16 +164,6 @@ const MessageRowComponent = ({
173
164
  ],
174
165
  );
175
166
 
176
- // Shift-click starts a native text selection on mousedown; suppressing it
177
- // there keeps the drag from painting a text range over the rows.
178
- const handleRowMouseDown = useCallback(
179
- (e: MouseEvent) => {
180
- if (!isDesktop) return;
181
- if (e.shiftKey) e.preventDefault();
182
- },
183
- [isDesktop],
184
- );
185
-
186
167
  const handleLongPress = useCallback(() => {
187
168
  onLongPress?.(messageId);
188
169
  }, [onLongPress, messageId]);
@@ -222,7 +203,8 @@ const MessageRowComponent = ({
222
203
  : {}),
223
204
  tabIndex: isTabStop ? 0 : -1,
224
205
  onClick: handleRowClick,
225
- onMouseDown: handleRowMouseDown,
206
+ onMouseDown: modifierSelect.onMouseDown,
207
+ onContextMenu: modifierSelect.onContextMenu,
226
208
  onMouseEnter: prefetchMessage,
227
209
  onFocus: handleRowFocus,
228
210
  },
@@ -11,6 +11,7 @@ import type { SelectionModifiers } from "@/hooks/useSelection";
11
11
  import { toDisplayCategory } from "@/lib/display-category";
12
12
  import { formatEmailDate } from "@/lib/format";
13
13
  import { MessageListItem } from "./MessageListItem";
14
+ import { useModifierSelect } from "./useModifierSelect";
14
15
 
15
16
  interface MailboxLinkSearch {
16
17
  selectedMessageId?: string;
@@ -20,8 +21,6 @@ interface MailboxLinkSearch {
20
21
  interface SwipeableMessageRowProps {
21
22
  thread: RemitImapThreadMessageResponse;
22
23
  mailboxId: string;
23
- /** Owning account, forwarded to `MessageListItem` for the auto-moved badge's undo action. */
24
- accountId?: string;
25
24
  isSelected: boolean;
26
25
  /** Roving keyboard focus cursor — renders the left accent rail (#429). */
27
26
  isFocused?: boolean;
@@ -46,7 +45,7 @@ const toThreadRowData = (
46
45
  const suspicious = thread.authenticity?.dkimMismatch === true;
47
46
  return {
48
47
  id: thread.messageId,
49
- accountId: thread.accountConfigId,
48
+ accountId: thread.accountId,
50
49
  fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
51
50
  fromEmail: thread.fromEmail ?? "",
52
51
  subject: thread.subject ?? "(No subject)",
@@ -64,7 +63,6 @@ const toThreadRowData = (
64
63
  export const SwipeableMessageRow = ({
65
64
  thread,
66
65
  mailboxId,
67
- accountId,
68
66
  isSelected,
69
67
  isFocused,
70
68
  isTabStop,
@@ -102,12 +100,16 @@ export const SwipeableMessageRow = ({
102
100
  onToggleCheck(thread.messageId);
103
101
  }, [onToggleCheck, thread.messageId]);
104
102
 
103
+ // The swipe row is the layout tier's row, not the input device's: a mouse at
104
+ // a narrow width still gets it, and a shift or cmd click on it belongs to
105
+ // selection rather than to the anchor's own new-window/new-tab gesture.
106
+ const modifierSelect = useModifierSelect(thread.messageId, onRowSelect);
107
+
105
108
  if (isDesktop || isMultiSelectMode) {
106
109
  return (
107
110
  <MessageListItem
108
111
  thread={thread}
109
112
  mailboxId={mailboxId}
110
- accountId={accountId}
111
113
  isSelected={isSelected}
112
114
  isFocused={isFocused}
113
115
  isTabStop={isTabStop}
@@ -145,7 +147,12 @@ export const SwipeableMessageRow = ({
145
147
  selectedMessageId: thread.messageId,
146
148
  })}
147
149
  data-message-row
148
- onClick={onOpenClick}
150
+ onMouseDown={modifierSelect.onMouseDown}
151
+ onContextMenu={modifierSelect.onContextMenu}
152
+ onClick={(e) => {
153
+ if (modifierSelect.claimClick(e)) return;
154
+ onOpenClick(e);
155
+ }}
149
156
  >
150
157
  {children}
151
158
  </Link>
@@ -28,6 +28,7 @@ import {
28
28
  useState,
29
29
  } from "react";
30
30
  import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
31
+ import { useFollowFocusOpen } from "@/hooks/useFollowFocusOpen";
31
32
  import { useListCursor } from "@/hooks/useListCursor";
32
33
  import { useIsDesktop } from "@/hooks/useMediaQuery";
33
34
  import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
@@ -52,6 +53,12 @@ interface ThreadListInteractionValue {
52
53
  exitSelection: () => void;
53
54
  /** Opens the move-to-Trash confirmation for the current selection. */
54
55
  requestDeleteSelection: () => void;
56
+ /** Rendered rows in display order — the same order a shift-range spans. */
57
+ orderedIds: string[];
58
+ /** Whether every rendered row is selected, for a select-all control. */
59
+ allSelected: boolean;
60
+ /** Select every rendered row, or clear when they already all are. */
61
+ toggleAllLoaded: () => void;
55
62
  }
56
63
 
57
64
  const ThreadListInteractionCtx =
@@ -121,10 +128,19 @@ const useRenderedRowIds = (
121
128
  return rowIds;
122
129
  };
123
130
 
131
+ /**
132
+ * How a row was opened. `replace: true` marks a reading-pane preview the cursor
133
+ * produced rather than a navigation the user asked for, so Back still leaves the
134
+ * list instead of walking the cursor's path back up it.
135
+ */
136
+ export interface OpenMessageOptions {
137
+ replace?: boolean;
138
+ }
139
+
124
140
  interface ThreadListInteractionProps {
125
141
  selectedMessageId: string | undefined;
126
142
  /** Opens a row — the same navigation a click performs. */
127
- onOpen: (messageId: string) => void;
143
+ onOpen: (messageId: string, options?: OpenMessageOptions) => void;
128
144
  /** Deletes a set of messages. Absent disables the delete key for this list. */
129
145
  onDeleteMessages?: (messageIds: string[]) => void;
130
146
  isDeleting?: boolean;
@@ -160,7 +176,14 @@ export function ThreadListInteraction({
160
176
  exitSelection,
161
177
  handleRowSelect,
162
178
  } = cursor;
163
- const { selectedIds, selectedCount, isSelected, toggle, select } = selection;
179
+ const { selectedIds, selectedCount, isSelected, toggle, select, toggleAll } =
180
+ selection;
181
+
182
+ const allSelected =
183
+ orderedIds.length > 0 && orderedIds.every((id) => selectedIds.has(id));
184
+ const toggleAllLoaded = useCallback(() => {
185
+ if (orderedIds.length > 0) toggleAll(orderedIds);
186
+ }, [orderedIds, toggleAll]);
164
187
 
165
188
  // A row that leaves the list — a chip filter, a collapsed section, a
166
189
  // completed delete — cannot stay selected. Survivors keep their selection.
@@ -199,6 +222,20 @@ export function ThreadListInteraction({
199
222
  if (focusedMessageId) onOpen(focusedMessageId);
200
223
  }, [focusedMessageId, onOpen]);
201
224
 
225
+ // The reading pane follows the cursor on desktop. Suspended while rows are
226
+ // selected: the cursor is then picking out a set, and the selection bar — not
227
+ // a message — is what the user is looking at.
228
+ const followOpen = useCallback(
229
+ (messageId: string) => onOpen(messageId, { replace: true }),
230
+ [onOpen],
231
+ );
232
+ useFollowFocusOpen({
233
+ keyboardFocusedMessageId: cursor.keyboardFocusedMessageId,
234
+ openMessageId: selectedMessageId,
235
+ enabled: isDesktop && selectedCount === 0,
236
+ open: followOpen,
237
+ });
238
+
202
239
  // Pending move-to-Trash, awaiting confirmation. The ids are snapshotted at
203
240
  // request time so a selection change behind the dialog cannot retarget it —
204
241
  // the same contract the mailbox list's delete has.
@@ -320,6 +357,9 @@ export function ThreadListInteraction({
320
357
  selectedCount,
321
358
  exitSelection,
322
359
  requestDeleteSelection,
360
+ orderedIds,
361
+ allSelected,
362
+ toggleAllLoaded,
323
363
  rowInteraction: (messageId: string) => ({
324
364
  focused: messageId === focusedMessageId,
325
365
  isTabStop: messageId === tabStop,
@@ -339,6 +379,9 @@ export function ThreadListInteraction({
339
379
  selectedCount,
340
380
  exitSelection,
341
381
  requestDeleteSelection,
382
+ orderedIds,
383
+ allSelected,
384
+ toggleAllLoaded,
342
385
  focusedMessageId,
343
386
  tabStop,
344
387
  isDesktop,
@@ -378,11 +421,13 @@ interface ThreadListSelectionBarProps {
378
421
  }
379
422
 
380
423
  /**
381
- * Selection bar for a list inside `ThreadListInteraction`.
424
+ * Reduced selection bar for Flagged, mounted inside `ThreadListInteraction`.
382
425
  *
383
- * Move is not offered here: the brief and Flagged span accounts and mailboxes,
384
- * and a move picker needs one account and one source folder to be honest about
385
- * where the messages go. Delete and mark-read carry no such scope.
426
+ * Move is not offered here: Flagged spans accounts and mailboxes, and a move
427
+ * picker needs one account and one source folder to be honest about where the
428
+ * messages go. Delete and mark-read carry no such scope. The brief resolves
429
+ * that scope from the selection instead and mounts the mailbox list's full
430
+ * `SelectionToolbar` — see `DailyBrief`.
386
431
  */
387
432
  export function ThreadListSelectionBar({
388
433
  onMarkAsRead,
@@ -4,16 +4,19 @@ import {
4
4
  FilterRuleEditor,
5
5
  type FolderOption,
6
6
  type LabelOption,
7
+ type RuleMatchMode,
7
8
  type RuleScope,
8
9
  } from "@remit/ui";
9
10
  import { useQuery } from "@tanstack/react-query";
10
11
  import { useEffect, useMemo, useRef, useState } from "react";
12
+ import { useClauseSuggestions } from "@/hooks/useClauseSuggestions";
11
13
  import { useCreateMailbox } from "@/hooks/useCreateMailbox";
12
14
  import { useCreateFilter } from "@/hooks/useFilters";
13
15
  import { useCreateLabel, useLabelList } from "@/hooks/useLabels";
14
16
  import { useOrganizeJob } from "@/hooks/useOrganizeJob";
15
17
  import { useRuleEditorState } from "@/hooks/useRuleEditorState";
16
18
  import { useRulePreview } from "@/hooks/useRulePreview";
19
+ import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
17
20
  import { getMailboxDisplayName } from "@/lib/folder-roles";
18
21
  import { buildMoveTargets } from "@/lib/move-targets";
19
22
  import {
@@ -22,6 +25,8 @@ import {
22
25
  } from "@/lib/organize/organize-model";
23
26
  import {
24
27
  buildInitialRule,
28
+ defaultMatchMode,
29
+ matchersForMode,
25
30
  rulePredicate,
26
31
  ruleToDraft,
27
32
  SUPPORTED_CLAUSE_FIELDS,
@@ -46,6 +51,12 @@ interface OrganizeRuleEditorProps {
46
51
  semanticUnavailable?: boolean;
47
52
  /** Distinct sender addresses, for the fallback clauses and the progress copy. */
48
53
  senders?: string[];
54
+ /**
55
+ * The mode the editor opens on. Omitted, it opens on the semantic widen
56
+ * wherever the deployment can run it — a caller passes `properties` only to
57
+ * enter the flow on a rule built from the selection's own properties.
58
+ */
59
+ seedMatchMode?: RuleMatchMode;
49
60
  /** A folder a "Something else" shortcut pre-picked. */
50
61
  seedMailboxId?: string;
51
62
  /** A scope a "Something else" shortcut pre-picked. */
@@ -70,28 +81,65 @@ export function OrganizeRuleEditor({
70
81
  seedCount,
71
82
  semanticUnavailable = false,
72
83
  senders = [],
84
+ seedMatchMode,
73
85
  seedMailboxId,
74
86
  seedScope,
75
87
  onClose,
76
88
  }: OrganizeRuleEditorProps) {
77
89
  const anchorMessageId = selectedMessageIds[0];
78
90
  const senderFallback = semanticUnavailable && senders.length > 0;
91
+ const subjects = useSelectedSubjects(selectedMessageIds);
92
+
93
+ // Semantic stays the default wherever the deployment can serve it; the
94
+ // properties mode is the third way in, not a replacement (RFC 038 D2).
95
+ const [matchMode, setMatchMode] = useState<RuleMatchMode>(
96
+ () => seedMatchMode ?? defaultMatchMode(semanticUnavailable),
97
+ );
79
98
 
80
99
  const [initialRule] = useState<FilterRule>(() =>
81
100
  buildInitialRule({
82
101
  anchorMessageId,
83
102
  semanticUnavailable,
103
+ matchMode,
84
104
  senders,
105
+ subjects,
85
106
  selectionCount: selectedMessageIds.length,
86
107
  seedMailboxId,
87
108
  seedScope,
88
109
  }),
89
110
  );
90
- const { rule, handlers } = useRuleEditorState({
111
+ const { rule, setRule, handlers } = useRuleEditorState({
91
112
  initialRule,
92
113
  widenAnchorCount: selectedMessageIds.length,
93
114
  });
94
115
 
116
+ // Switching mode rebuilds only what the rule matches on — the derived chips
117
+ // and the widen. The destination, label, scope, name, and any clause the user
118
+ // typed themselves survive, so the choice stays reversible.
119
+ const changeMatchMode = (next: RuleMatchMode) => {
120
+ if (next === matchMode) return;
121
+ setMatchMode(next);
122
+ setRule((current) => ({
123
+ ...current,
124
+ ...matchersForMode(next, {
125
+ anchorMessageId,
126
+ senders,
127
+ subjects,
128
+ selectionCount: selectedMessageIds.length,
129
+ keepClauses: current.clauses.filter((clause) => !clause.derived),
130
+ currentOperator: current.matchOperator,
131
+ }),
132
+ }));
133
+ };
134
+
135
+ // The selection's own senders are the likeliest values for an address clause
136
+ // and are already in hand, so they lead the list before anything is typed.
137
+ const clauseSuggestions = useClauseSuggestions(
138
+ handlers.clauseEdit?.draft.field,
139
+ handlers.clauseEdit?.draft.value ?? "",
140
+ senders,
141
+ );
142
+
95
143
  const { data: mailboxesData } = useQuery({
96
144
  ...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
97
145
  staleTime: Number.POSITIVE_INFINITY,
@@ -178,7 +226,9 @@ export function OrganizeRuleEditor({
178
226
  runningLabel={
179
227
  senderFallback
180
228
  ? "Organizing mail from these senders…"
181
- : "Organizing similar mail…"
229
+ : matchMode === "properties"
230
+ ? "Organizing matching mail…"
231
+ : "Organizing similar mail…"
182
232
  }
183
233
  onClose={onClose}
184
234
  />
@@ -219,7 +269,12 @@ export function OrganizeRuleEditor({
219
269
  labels={labels}
220
270
  preview={preview}
221
271
  semanticAvailable={!semanticUnavailable}
272
+ // A deployment without the vector pipeline has only one mode to be in,
273
+ // so it gets no choice to make — the rule already matches on properties.
274
+ matchMode={semanticUnavailable ? undefined : matchMode}
275
+ onChangeMatchMode={changeMatchMode}
222
276
  clauseFields={SUPPORTED_CLAUSE_FIELDS}
277
+ clauseSuggestions={clauseSuggestions}
223
278
  {...handlers}
224
279
  onCreateFolder={createFolder}
225
280
  onCreateLabel={onCreateLabel}
@@ -10,7 +10,7 @@ import { SearchFilterDialog } from "./SearchFilterDialog";
10
10
  // runtime, which references a global `React`.
11
11
  (globalThis as { React?: typeof React }).React = React;
12
12
 
13
- const render = (open: boolean) =>
13
+ const render = (open: boolean, query = "from:receipts@stripe.com") =>
14
14
  renderToString(
15
15
  createElement(
16
16
  QueryClientProvider,
@@ -18,7 +18,7 @@ const render = (open: boolean) =>
18
18
  createElement(SearchFilterDialog, {
19
19
  open,
20
20
  accountId: "acc-1",
21
- parsed: parseSearchTokens("receipts", {}),
21
+ parsed: parseSearchTokens(query, {}),
22
22
  searchHadSemanticReach: true,
23
23
  onClose: () => undefined,
24
24
  }),
@@ -33,4 +33,15 @@ describe("SearchFilterDialog", () => {
33
33
  it("shows the conversion step while the seed preview is in flight", () => {
34
34
  assert.match(render(true), /Turning your search into a filter/);
35
35
  });
36
+
37
+ it("opens the editor for a free-text search instead of failing to count it", () => {
38
+ // The free text converts to a `HasWords` clause, which the vector-free
39
+ // matcher refuses outright. Asking for a count is a 500, so nothing is
40
+ // asked: the editor opens and the count region carries the reason.
41
+ const html = render(true, "receipts");
42
+ assert.doesNotMatch(html, /Turning your search into a filter/);
43
+ assert.doesNotMatch(html, /Couldn't build the filter/);
44
+ assert.match(html, /These chips are the whole rule/);
45
+ assert.match(html, /reads message bodies/);
46
+ });
36
47
  });