@remit/web-client 0.0.277 → 0.0.279

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.277",
3
+ "version": "0.0.279",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -159,16 +159,25 @@ function IntelligenceSkeleton() {
159
159
  /**
160
160
  * Reclassify picker: a small modal listing the category-override options. On
161
161
  * selection it PATCHes `AddressFlags.category` for the sender.
162
+ *
163
+ * A sender that already carries an override also gets the way back out. The
164
+ * override is an enum with no false-equivalent member, so removal is not a
165
+ * value to send — it is `clearFlags: ["category"]`, the contract's removal
166
+ * form for every flag (#615).
162
167
  */
163
168
  function ReclassifyDialog({
164
169
  isOpen,
165
170
  current,
171
+ hasOverride,
166
172
  onSelect,
173
+ onClear,
167
174
  onCancel,
168
175
  }: {
169
176
  isOpen: boolean;
170
177
  current: string;
178
+ hasOverride: boolean;
171
179
  onSelect: (category: CategoryOverride) => void;
180
+ onClear: () => void;
172
181
  onCancel: () => void;
173
182
  }) {
174
183
  const dialogRef = useRef<HTMLDivElement>(null);
@@ -210,6 +219,15 @@ function ReclassifyDialog({
210
219
  )}
211
220
  </button>
212
221
  ))}
222
+ {hasOverride && (
223
+ <button
224
+ type="button"
225
+ onClick={onClear}
226
+ className="mt-1 flex min-h-11 items-center rounded border-t border-line px-3 text-left text-sm text-fg transition-colors hover:bg-surface-raised"
227
+ >
228
+ Remove override — classify automatically again
229
+ </button>
230
+ )}
213
231
  </div>
214
232
  <div className="mt-6 flex justify-end">
215
233
  <button
@@ -247,6 +265,7 @@ function WiredPanel({
247
265
  }: WiredPanelProps) {
248
266
  const {
249
267
  data,
268
+ address,
250
269
  addressId,
251
270
  isSimilarLoading,
252
271
  similarError,
@@ -256,7 +275,7 @@ function WiredPanel({
256
275
  const [reclassifyOpen, setReclassifyOpen] = useState(false);
257
276
  const senderEmail = thread.fromEmail ?? undefined;
258
277
 
259
- const { updateFlags } = useUpdateAddressFlags({
278
+ const { updateFlags, clearFlags } = useUpdateAddressFlags({
260
279
  addressId,
261
280
  senderEmail,
262
281
  });
@@ -315,6 +334,11 @@ function WiredPanel({
315
334
  [updateFlags],
316
335
  );
317
336
 
337
+ const handleReclassifyClear = useCallback(() => {
338
+ setReclassifyOpen(false);
339
+ clearFlags(["category"]);
340
+ }, [clearFlags]);
341
+
318
342
  // Every per-sender flag toggle PATCHes the sender's address row, so none of
319
343
  // them can be serviced until that row resolves. Leave them unwired until it
320
344
  // does: the panel renders an unwired quick action as disabled, so the flow is
@@ -383,7 +407,9 @@ function WiredPanel({
383
407
  <ReclassifyDialog
384
408
  isOpen={reclassifyOpen}
385
409
  current={data.category.value}
410
+ hasOverride={address?.flags?.category?.value != null}
386
411
  onSelect={handleReclassifySelect}
412
+ onClear={handleReclassifyClear}
387
413
  onCancel={() => setReclassifyOpen(false)}
388
414
  />
389
415
  </>
@@ -4,7 +4,6 @@ import {
4
4
  type ClauseDraft,
5
5
  type ClauseEditState,
6
6
  derivePropertyClauses,
7
- deriveSenderClauses,
8
7
  dominantSender,
9
8
  type FolderTreeNode,
10
9
  type MatchCount,
@@ -403,6 +402,7 @@ function SelectionWizardSession({
403
402
  escalated ? undefined : accountId,
404
403
  anchorMessageId,
405
404
  senders,
405
+ subjects,
406
406
  );
407
407
  const { preview: probeWiden } = widen;
408
408
  // The similar door has to know before it is pressed whether it can run, so
@@ -581,20 +581,22 @@ function SelectionWizardSession({
581
581
  [anchorMessageId, selection.length, seedPropertyClauses],
582
582
  );
583
583
 
584
- // The dimmed similar door, pressed. The senders the widen would have fallen
585
- // back to are filled in on the property step instead of standing in for the
586
- // semantic match without saying so (#477 3.6).
584
+ // The dimmed similar door, pressed. What the widen would have fallen back to
585
+ // is filled in on the property step instead of standing in for the semantic
586
+ // match without saying so (#477 3.6) — the same agreement the property step
587
+ // itself seeds from (#458), not senders alone: a selection with nothing in
588
+ // common on sender still shares a subject worth matching on.
587
589
  const takeSemanticFallback = useCallback(() => {
588
590
  setSemanticFallbackTaken(true);
589
591
  setDoor("properties");
590
592
  setDraft((held) => ({
591
593
  ...held,
592
594
  widen: undefined,
593
- clauses: withIds(deriveSenderClauses(senders), "sender"),
595
+ clauses: seedPropertyClauses(),
594
596
  matchOperator: "any",
595
597
  }));
596
598
  goToStep("properties");
597
- }, [senders, goToStep]);
599
+ }, [seedPropertyClauses, goToStep]);
598
600
 
599
601
  // The probe lands after the door is on screen, so it can report the widen
600
602
  // unavailable while the user is already holding it. The fallback is taken then
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Removing a sender's category override (#615).
3
+ *
4
+ * A sender flag is removed by naming its key in `clearFlags`. `category` is the
5
+ * flag that has no other way out: its value is a `MessageCategory` enum with no
6
+ * false-equivalent member, so the `{ value: false }` form the boolean flags use
7
+ * cannot express "no override", and the `null` form the schema documents is
8
+ * rejected by the request validator. Before this the reclassify picker was
9
+ * one-way — a reader could set an override and never take it back.
10
+ *
11
+ * Drives the real chain: the real `IntelligencePane`, the real
12
+ * `useUpdateAddressFlags` mutation, and a fetch mock that answers the way the
13
+ * server does. Both halves are asserted — the body that goes on the wire, and
14
+ * that the override is gone from what the panel renders.
15
+ */
16
+
17
+ import assert from "node:assert/strict";
18
+ import { afterEach, describe, it } from "node:test";
19
+ import type { RemitImapAddressResponse } from "@remit/api-http-client/types.gen.ts";
20
+ import { createElement } from "react";
21
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
22
+ import {
23
+ makeAccount,
24
+ makeConfig,
25
+ makeThreadMessage,
26
+ } from "@/test-support/fixtures";
27
+ import { type HttpMock, mockFetch } from "@/test-support/http";
28
+ import { IntelligencePane } from "./IntelligencePane";
29
+
30
+ const thread = makeThreadMessage({ messageId: "msg-1", category: "personal" });
31
+
32
+ const addressWith = (
33
+ flags: RemitImapAddressResponse["flags"],
34
+ ): RemitImapAddressResponse => ({
35
+ addressId: "addr-1",
36
+ accountConfigId: "cfg-1",
37
+ displayName: "Alice",
38
+ localPart: "alice",
39
+ domain: "example.com",
40
+ normalizedEmail: "alice@example.com",
41
+ flags,
42
+ inboundCount: 6,
43
+ outboundCount: 1,
44
+ replyCount: 2,
45
+ lastInboundAt: 1_000,
46
+ lastReplyAt: 900,
47
+ createdAt: 0,
48
+ updatedAt: 0,
49
+ });
50
+
51
+ let harness: DomHarness | undefined;
52
+ let http: HttpMock;
53
+
54
+ afterEach(() => {
55
+ harness?.close();
56
+ harness = undefined;
57
+ http.restore();
58
+ });
59
+
60
+ describe("the reclassify picker on a sender that already carries an override", () => {
61
+ it("removes the override with clearFlags, and the panel stops claiming one", async () => {
62
+ let stored: RemitImapAddressResponse = addressWith({
63
+ category: { value: "newsletter", setAt: 10 },
64
+ });
65
+
66
+ http = mockFetch((call) => {
67
+ if (call.path.endsWith("/config")) {
68
+ return makeConfig([makeAccount({ accountId: "acc-1" })], {
69
+ semanticSearchEnabled: false,
70
+ });
71
+ }
72
+ if (call.path.endsWith("/addresses/search")) {
73
+ return { items: [stored] };
74
+ }
75
+ if (call.method === "PATCH" && call.path.endsWith("/addresses/addr-1")) {
76
+ stored = addressWith({});
77
+ return stored;
78
+ }
79
+ return { items: [] };
80
+ });
81
+
82
+ harness = createDomHarness();
83
+ harness.renderApp(
84
+ createElement(IntelligencePane, { thread, onClose: () => {} }),
85
+ );
86
+
87
+ await harness.waitFor(
88
+ () => harness?.text().includes("your override") === true,
89
+ "the panel never showed the sender's category override",
90
+ );
91
+
92
+ harness.click(harness.byText("button", "reclassify"));
93
+ harness.click(
94
+ harness.byText(
95
+ "button",
96
+ "Remove override — classify automatically again",
97
+ ),
98
+ );
99
+
100
+ await harness.waitFor(
101
+ () => http.to("/addresses/addr-1").length === 1,
102
+ "the remove control sent no request",
103
+ );
104
+
105
+ assert.deepEqual(http.to("/addresses/addr-1")[0].body, {
106
+ clearFlags: ["category"],
107
+ });
108
+
109
+ await harness.waitFor(
110
+ () => harness?.text().includes("your override") === false,
111
+ "the override survived its own removal",
112
+ );
113
+ });
114
+
115
+ it("offers no removal on a sender that carries no override", async () => {
116
+ http = mockFetch((call) => {
117
+ if (call.path.endsWith("/config")) {
118
+ return makeConfig([makeAccount({ accountId: "acc-1" })], {
119
+ semanticSearchEnabled: false,
120
+ });
121
+ }
122
+ if (call.path.endsWith("/addresses/search")) {
123
+ return { items: [addressWith({})] };
124
+ }
125
+ return { items: [] };
126
+ });
127
+
128
+ harness = createDomHarness();
129
+ harness.renderApp(
130
+ createElement(IntelligencePane, { thread, onClose: () => {} }),
131
+ );
132
+
133
+ await harness.waitFor(
134
+ () => harness?.text().includes("reclassify") === true,
135
+ "the panel never rendered the category section",
136
+ );
137
+
138
+ harness.click(harness.byText("button", "reclassify"));
139
+
140
+ assert.equal(harness.text().includes("Remove override"), false);
141
+ });
142
+ });
@@ -2,9 +2,10 @@
2
2
  * The widen step's two-phase preview. On a semantic-capable deployment it runs
3
3
  * the anchor preview once and stops. On a deployment that reports
4
4
  * `semanticUnavailable` (no vector pipeline — semantic-capability.ts) it
5
- * re-previews with sender-derived literal clauses — one `From` clause per
6
- * distinct sender, combined with `Or`, no anchor — and reports the literal match
7
- * count, so every commit scope acts on the sender-matched set.
5
+ * re-previews with what the whole selection agrees on (#458) — one `From` or
6
+ * `FromDomain` clause when senders agree, the shared subject when they don't,
7
+ * combined with `Or`, no anchor — and reports the literal match count, so
8
+ * every commit scope acts on the same set the fallback derived.
8
9
  */
9
10
 
10
11
  import assert from "node:assert/strict";
@@ -29,12 +30,16 @@ type Responder = Parameters<typeof mockFetch>[0];
29
30
 
30
31
  const PREVIEW_PATH = "/organize/preview";
31
32
 
32
- const mount = (senders: string[], responder: Responder) => {
33
+ const mount = (
34
+ senders: string[],
35
+ responder: Responder,
36
+ subjects: string[] = [],
37
+ ) => {
33
38
  http = mockFetch(responder);
34
39
  harness = createDomHarness();
35
40
  const holder: { current: Widen | undefined } = { current: undefined };
36
41
  function Probe() {
37
- const widen = useOrganizeWiden("acc-1", "msg-1", senders);
42
+ const widen = useOrganizeWiden("acc-1", "msg-1", senders, subjects);
38
43
  holder.current = widen;
39
44
  const { preview } = widen;
40
45
  useEffect(() => {
@@ -130,6 +135,39 @@ describe("useOrganizeWiden — no vector pipeline, senders present", () => {
130
135
  });
131
136
  });
132
137
 
138
+ describe("useOrganizeWiden — no vector pipeline, mixed senders sharing a subject", () => {
139
+ it("re-previews with the shared subject rather than one From clause per sender (#458)", async () => {
140
+ const holder = mount(
141
+ ["billing@acme.test", "accounts@globex.test"],
142
+ (call) =>
143
+ call.body?.anchorMessageId
144
+ ? { matchedCount: 0, messageIds: [], semanticUnavailable: true }
145
+ : {
146
+ matchedCount: 64,
147
+ messageIds: ["m"],
148
+ semanticUnavailable: false,
149
+ },
150
+ ["Invoice 1841", "Invoice 1902"],
151
+ );
152
+ await settle(2);
153
+
154
+ const calls = http?.to(PREVIEW_PATH) ?? [];
155
+ assert.equal(calls.length, 2);
156
+ const second = calls[1];
157
+ assert.equal(second.body?.anchorMessageId, undefined);
158
+ assert.equal(second.body?.matchOperator, "Or");
159
+ assert.deepEqual(second.body?.literalClauses, [
160
+ { field: "Subject", value: "Invoice" },
161
+ ]);
162
+
163
+ const widen = holder.current;
164
+ assert.deepEqual(widen?.matchPredicate, {
165
+ matchOperator: "Or",
166
+ literalClauses: [{ field: "Subject", value: "Invoice" }],
167
+ });
168
+ });
169
+ });
170
+
133
171
  describe("useOrganizeWiden — no vector pipeline, no senders", () => {
134
172
  it("does not re-preview and stays capability-absent with the anchor predicate", async () => {
135
173
  const holder = mount([], () => ({
@@ -148,4 +186,32 @@ describe("useOrganizeWiden — no vector pipeline, no senders", () => {
148
186
  assert.deepEqual(widen?.senders, []);
149
187
  assert.equal(widen?.matchPredicate.anchorMessageId, "msg-1");
150
188
  });
189
+
190
+ it("still re-previews on a shared subject alone (#458)", async () => {
191
+ const holder = mount(
192
+ [],
193
+ (call) =>
194
+ call.body?.anchorMessageId
195
+ ? { matchedCount: 0, messageIds: [], semanticUnavailable: true }
196
+ : {
197
+ matchedCount: 12,
198
+ messageIds: ["m"],
199
+ semanticUnavailable: false,
200
+ },
201
+ ["Invoice 1841", "Invoice 1902"],
202
+ );
203
+ await settle(2);
204
+
205
+ const calls = http?.to(PREVIEW_PATH) ?? [];
206
+ assert.equal(calls.length, 2);
207
+ const second = calls[1];
208
+ assert.equal(second.body?.anchorMessageId, undefined);
209
+ assert.deepEqual(second.body?.literalClauses, [
210
+ { field: "Subject", value: "Invoice" },
211
+ ]);
212
+
213
+ const widen = holder.current;
214
+ assert.equal(widen?.semanticUnavailable, true);
215
+ assert.equal(widen?.senderFallback, true);
216
+ });
151
217
  });
@@ -1,5 +1,5 @@
1
1
  import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import { deriveSenderClauses, distinctSenders } from "@remit/ui";
2
+ import { derivePropertyClauses, distinctSenders } from "@remit/ui";
3
3
  import { useMutation } from "@tanstack/react-query";
4
4
  import { useCallback, useEffect, useMemo, useState } from "react";
5
5
  import { buildOrganizeInput } from "@/lib/organize/organize-model";
@@ -9,16 +9,17 @@ import {
9
9
  } from "@/lib/organize/sender-fallback";
10
10
 
11
11
  /**
12
- * "Select similar messages" (the widen step) with a sender-derived fallback for
13
- * deployments without the vector pipeline.
12
+ * "Select similar messages" (the widen step) with a selection-agreement
13
+ * fallback for deployments without the vector pipeline.
14
14
  *
15
15
  * The first preview runs the semantic anchor (POST /organize/preview). When the
16
16
  * server reports `semanticUnavailable` — no vector pipeline on this deployment
17
17
  * (semantic-capability.ts) — the widen re-previews with the literal vocabulary
18
- * that matches vector-free: one `From` clause per distinct sender in the
19
- * selection, combined with `Or`, no anchor. The counts shown, the one-time
20
- * back-apply, and any standing filter then all carry that same literal
21
- * predicate, so the previewed set equals the set every commit scope acts on.
18
+ * that matches vector-free: what the whole selection agrees on (#458) — one
19
+ * sender/domain clause, or the subject the selection shares when senders don't
20
+ * — combined with `Or`, no anchor. The counts shown, the one-time back-apply,
21
+ * and any standing filter then all carry that same literal predicate, so the
22
+ * previewed set equals the set every commit scope acts on.
22
23
  *
23
24
  * A semantic-capable deployment never fires the second preview and keeps exactly
24
25
  * the anchor behaviour.
@@ -27,12 +28,17 @@ export const useOrganizeWiden = (
27
28
  accountId: string | undefined,
28
29
  anchorMessageId: string | undefined,
29
30
  senders: readonly string[],
31
+ subjects: readonly string[],
30
32
  ) => {
31
33
  const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
32
34
  const { mutate, reset: mutationReset } = mutation;
33
35
  const [fellBack, setFellBack] = useState(false);
34
36
 
35
37
  const distinct = useMemo(() => distinctSenders(senders), [senders]);
38
+ const agreementClauses = useMemo(
39
+ () => derivePropertyClauses(distinct, subjects),
40
+ [distinct, subjects],
41
+ );
36
42
 
37
43
  const preview = useCallback(() => {
38
44
  if (!accountId || !anchorMessageId) return;
@@ -53,21 +59,24 @@ export const useOrganizeWiden = (
53
59
  }, [mutationReset]);
54
60
 
55
61
  const data = mutation.data;
56
- // The first preview came back capability-absent and there are senders to
57
- // match on: re-preview with the literal clauses before showing any count, so
58
- // the count the user sees is the sender-match count, never a flash of the
59
- // empty semantic result.
62
+ // The first preview came back capability-absent and the selection agrees on
63
+ // something to match on — a sender, a domain, or just a shared subject, not
64
+ // senders alone (#458): re-preview with the literal clauses before showing
65
+ // any count, so the count the user sees is the agreement's match count,
66
+ // never a flash of the empty semantic result.
60
67
  const willFallBack =
61
- data?.semanticUnavailable === true && !fellBack && distinct.length > 0;
68
+ data?.semanticUnavailable === true &&
69
+ !fellBack &&
70
+ agreementClauses.length > 0;
62
71
 
63
72
  useEffect(() => {
64
73
  if (!willFallBack || !accountId) return;
65
74
  setFellBack(true);
66
75
  mutate({
67
76
  path: { accountId },
68
- body: buildOrganizeInput(buildSenderFallbackDraft(distinct)),
77
+ body: buildOrganizeInput(buildSenderFallbackDraft(distinct, subjects)),
69
78
  });
70
- }, [willFallBack, accountId, distinct, mutate]);
79
+ }, [willFallBack, accountId, distinct, subjects, mutate]);
71
80
 
72
81
  // The literal re-preview has no anchor, so its response reports
73
82
  // `semanticUnavailable: false`; `fellBack` is what remembers the capability
@@ -76,7 +85,7 @@ export const useOrganizeWiden = (
76
85
  const isPending = mutation.isPending || willFallBack;
77
86
 
78
87
  const matchPredicate: OrganizeMatchPredicate = fellBack
79
- ? { matchOperator: "Or", literalClauses: deriveSenderClauses(distinct) }
88
+ ? { matchOperator: "Or", literalClauses: agreementClauses }
80
89
  : {
81
90
  ...(anchorMessageId ? { anchorMessageId } : {}),
82
91
  matchOperator: "And",
@@ -4,6 +4,7 @@ import {
4
4
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
5
  import type {
6
6
  AddressOperationsSearchAddressesResponse,
7
+ RemitImapAddressFlagKey,
7
8
  RemitImapAddressFlags,
8
9
  RemitImapUpdateAddressFlagsInput,
9
10
  } from "@remit/api-http-client/types.gen.ts";
@@ -25,15 +26,17 @@ interface MutationContext {
25
26
  }
26
27
 
27
28
  /**
28
- * Apply a flag-update patch onto an existing AddressFlags object. Each key in
29
- * `patch` is a flag update object (set) or `null` (remove). Mirrors the
30
- * server-side merge semantics so the optimistic cache matches what the API
31
- * returns. Typed loosely (the per-key flag value types differ — boolean for
32
- * most, a category enum for `category`) and re-narrowed at the return.
29
+ * Apply a flag update onto an existing AddressFlags object. Each key in
30
+ * `patch` is a flag update object; each key in `clear` is removed outright,
31
+ * after the patch, matching the server's ordering. Mirrors the server-side
32
+ * merge semantics so the optimistic cache matches what the API returns. Typed
33
+ * loosely (the per-key flag value types differ — boolean for most, a category
34
+ * enum for `category`) and re-narrowed at the return.
33
35
  */
34
36
  function applyFlagPatch(
35
37
  current: RemitImapAddressFlags | undefined,
36
38
  patch: RemitImapUpdateAddressFlagsInput,
39
+ clear: readonly RemitImapAddressFlagKey[] = [],
37
40
  ): RemitImapAddressFlags {
38
41
  const next: Record<string, unknown> = { ...(current ?? {}) };
39
42
  for (const [key, update] of Object.entries(patch)) {
@@ -45,6 +48,9 @@ function applyFlagPatch(
45
48
  next[key] = { ...update, setAt: update.setAt ?? Date.now() };
46
49
  }
47
50
  }
51
+ for (const key of clear) {
52
+ delete next[key];
53
+ }
48
54
  return next as RemitImapAddressFlags;
49
55
  }
50
56
 
@@ -69,6 +75,7 @@ export function useUpdateAddressFlags({
69
75
  ...addressDetailOperationsUpdateAddressMutation(),
70
76
  onMutate: async (vars): Promise<MutationContext> => {
71
77
  const patch = vars.body.flags ?? {};
78
+ const clear = vars.body.clearFlags ?? [];
72
79
  await queryClient.cancelQueries({ queryKey: addressCacheKey });
73
80
 
74
81
  const previous =
@@ -84,7 +91,7 @@ export function useUpdateAddressFlags({
84
91
  ...old,
85
92
  items: old.items.map((addr) =>
86
93
  addr.addressId === vars.path.addressId
87
- ? { ...addr, flags: applyFlagPatch(addr.flags, patch) }
94
+ ? { ...addr, flags: applyFlagPatch(addr.flags, patch, clear) }
88
95
  : addr,
89
96
  ),
90
97
  };
@@ -108,8 +115,11 @@ export function useUpdateAddressFlags({
108
115
  },
109
116
  });
110
117
 
111
- const updateFlags = useCallback(
112
- (flags: RemitImapUpdateAddressFlagsInput) => {
118
+ const submit = useCallback(
119
+ (body: {
120
+ flags?: RemitImapUpdateAddressFlagsInput;
121
+ clearFlags?: RemitImapAddressFlagKey[];
122
+ }) => {
113
123
  if (!addressId) {
114
124
  reportFatalError(
115
125
  new Error(
@@ -118,10 +128,25 @@ export function useUpdateAddressFlags({
118
128
  );
119
129
  return;
120
130
  }
121
- mutate({ path: { addressId }, body: { flags } });
131
+ mutate({ path: { addressId }, body });
122
132
  },
123
133
  [addressId, mutate, senderEmail],
124
134
  );
125
135
 
126
- return { updateFlags, isPending };
136
+ const updateFlags = useCallback(
137
+ (flags: RemitImapUpdateAddressFlagsInput) => submit({ flags }),
138
+ [submit],
139
+ );
140
+
141
+ /**
142
+ * Remove flags outright. This is the removal form for every flag whatever
143
+ * its value type: `category` holds an enum with no false-equivalent member,
144
+ * so `{ value: false }` cannot express "no override" for it.
145
+ */
146
+ const clearFlags = useCallback(
147
+ (keys: RemitImapAddressFlagKey[]) => submit({ clearFlags: keys }),
148
+ [submit],
149
+ );
150
+
151
+ return { updateFlags, clearFlags, isPending };
127
152
  }
@@ -4,7 +4,10 @@ import { buildSenderFallbackDraft } from "./sender-fallback";
4
4
 
5
5
  describe("buildSenderFallbackDraft", () => {
6
6
  it("combines the sender clauses with Or and carries no anchor", () => {
7
- const draft = buildSenderFallbackDraft(["npm@github.com", "a@x.com"]);
7
+ const draft = buildSenderFallbackDraft(
8
+ ["npm@github.com", "a@x.com"],
9
+ ["Deploy failed", "Standup notes"],
10
+ );
8
11
  assert.equal(draft.matchOperator, "Or");
9
12
  assert.equal(draft.anchorMessageId, undefined);
10
13
  assert.deepEqual(draft.literalClauses, [
@@ -14,10 +17,36 @@ describe("buildSenderFallbackDraft", () => {
14
17
  });
15
18
 
16
19
  it("carries a single FromDomain clause when the senders share a domain", () => {
17
- const draft = buildSenderFallbackDraft(["npm@github.com", "ci@github.com"]);
20
+ const draft = buildSenderFallbackDraft(
21
+ ["npm@github.com", "ci@github.com"],
22
+ ["Deploy failed", "Standup notes"],
23
+ );
18
24
  assert.equal(draft.matchOperator, "Or");
19
25
  assert.deepEqual(draft.literalClauses, [
20
26
  { field: "FromDomain", value: "github.com" },
21
27
  ]);
22
28
  });
29
+
30
+ it("falls back to a shared subject when mixed senders share no domain (#458)", () => {
31
+ const draft = buildSenderFallbackDraft(
32
+ ["billing@acme.test", "accounts@globex.test"],
33
+ ["Invoice 1841", "Invoice 1902"],
34
+ );
35
+ assert.equal(draft.matchOperator, "Or");
36
+ assert.deepEqual(draft.literalClauses, [
37
+ { field: "Subject", value: "Invoice" },
38
+ ]);
39
+ });
40
+
41
+ it("offers one sender chip each when mixed senders share no subject either", () => {
42
+ const draft = buildSenderFallbackDraft(
43
+ ["billing@acme.test", "accounts@globex.test"],
44
+ ["Invoice 1841", "Standup notes"],
45
+ );
46
+ assert.equal(draft.matchOperator, "Or");
47
+ assert.deepEqual(draft.literalClauses, [
48
+ { field: "From", value: "billing@acme.test" },
49
+ { field: "From", value: "accounts@globex.test" },
50
+ ]);
51
+ });
23
52
  });
@@ -1,26 +1,30 @@
1
1
  import type { RemitImapOrganizeInput } from "@remit/api-http-client/types.gen.ts";
2
- import { deriveSenderClauses } from "@remit/ui";
2
+ import { derivePropertyClauses } from "@remit/ui";
3
3
  import type { OrganizeDraft } from "./organize-model";
4
4
 
5
5
  /**
6
6
  * The widen fallback for a deployment that ships no vector pipeline (self-host
7
7
  * sqlite — semantic-capability.ts). The semantic anchor matches nothing there,
8
8
  * so a widen degrades to the literal vocabulary RFC 031 already matches
9
- * vector-free: the sender clauses `@remit/ui` derives, combined with `Or`, no
10
- * anchor. The same predicate matches at index time (RFC 034), so a standing
11
- * filter built from it keeps working on future mail.
9
+ * vector-free: what the whole selection agrees on, combined with `Or`, no
10
+ * anchor (#458) — the same evidence order the properties door prefills from,
11
+ * so a selection with nothing in common on sender still falls back to a
12
+ * subject it shares rather than one `From` chip per message. The same
13
+ * predicate matches at index time (RFC 034), so a standing filter built from
14
+ * it keeps working on future mail.
12
15
  */
13
16
 
14
17
  /**
15
- * The literal predicate that stands in for the semantic anchor: the sender
16
- * clauses combined with `Or` and no anchor. The preview, the one-time
17
- * back-apply, and the standing filter all carry exactly this.
18
+ * The literal predicate that stands in for the semantic anchor: the clauses
19
+ * the selection agrees on, combined with `Or` and no anchor. The preview, the
20
+ * one-time back-apply, and the standing filter all carry exactly this.
18
21
  */
19
22
  export const buildSenderFallbackDraft = (
20
23
  senders: readonly string[],
24
+ subjects: readonly string[],
21
25
  ): OrganizeDraft => ({
22
26
  matchOperator: "Or",
23
- literalClauses: deriveSenderClauses(senders),
27
+ literalClauses: derivePropertyClauses(senders, subjects),
24
28
  });
25
29
 
26
30
  /**