@remit/web-client 0.0.278 → 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.278",
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": {
@@ -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
@@ -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,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
  /**