@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.
|
|
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.
|
|
585
|
-
//
|
|
586
|
-
//
|
|
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:
|
|
595
|
+
clauses: seedPropertyClauses(),
|
|
594
596
|
matchOperator: "any",
|
|
595
597
|
}));
|
|
596
598
|
goToStep("properties");
|
|
597
|
-
}, [
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 = (
|
|
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 {
|
|
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
|
|
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:
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
57
|
-
// match on
|
|
58
|
-
//
|
|
59
|
-
//
|
|
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 &&
|
|
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:
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
|
10
|
-
* anchor
|
|
11
|
-
*
|
|
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
|
|
16
|
-
*
|
|
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:
|
|
27
|
+
literalClauses: derivePropertyClauses(senders, subjects),
|
|
24
28
|
});
|
|
25
29
|
|
|
26
30
|
/**
|