@remit/web-client 0.0.63 → 0.0.64
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 +1 -1
- package/src/components/mail/MessageList.tsx +15 -0
- package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +1 -0
- package/src/components/mail/organize/MobileOrganizeFlow.tsx +14 -6
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +1 -0
- package/src/components/mail/organize/OrganizeDialog.tsx +15 -10
- package/src/components/mail/organize/OrganizePanel.progress.test.ts +113 -0
- package/src/components/mail/organize/OrganizePanel.render.test.ts +32 -2
- package/src/components/mail/organize/OrganizePanel.tsx +58 -20
- package/src/components/mail/organize/smart-organize.stories.tsx +94 -5
- package/src/hooks/useOrganizeWiden.test.ts +151 -0
- package/src/hooks/useOrganizeWiden.ts +104 -0
- package/src/lib/organize/organize-copy.test.ts +41 -0
- package/src/lib/organize/organize-copy.ts +29 -0
- package/src/lib/organize/sender-fallback.test.ts +56 -0
- package/src/lib/organize/sender-fallback.ts +64 -0
- package/src/hooks/useOrganizePreview.ts +0 -43
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.64",
|
|
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": {
|
|
@@ -474,6 +474,19 @@ export const MessageList = ({
|
|
|
474
474
|
// handled the click (caller should preventDefault and skip navigation);
|
|
475
475
|
// false for a plain click (caller lets the Link navigate).
|
|
476
476
|
const orderedIds = useMemo(() => threads.map((t) => t.messageId), [threads]);
|
|
477
|
+
|
|
478
|
+
// Sender addresses of the checked rows, for the organize widen's literal
|
|
479
|
+
// fallback when this deployment ships no vector pipeline (the sheet matches
|
|
480
|
+
// all mail from these senders). Read off the already-loaded thread rows, so
|
|
481
|
+
// no extra round-trip; the widen hook dedupes.
|
|
482
|
+
const selectedSenders = useMemo(() => {
|
|
483
|
+
const emails: string[] = [];
|
|
484
|
+
for (const thread of threads) {
|
|
485
|
+
if (!selectedIds.has(thread.messageId)) continue;
|
|
486
|
+
if (thread.fromEmail) emails.push(thread.fromEmail);
|
|
487
|
+
}
|
|
488
|
+
return emails;
|
|
489
|
+
}, [threads, selectedIds]);
|
|
477
490
|
const handleRowSelect = useCallback(
|
|
478
491
|
(messageId: string, modifiers: SelectionModifiers): boolean => {
|
|
479
492
|
if (modifiers.shiftKey) {
|
|
@@ -1256,6 +1269,7 @@ export const MessageList = ({
|
|
|
1256
1269
|
accountId={accountId}
|
|
1257
1270
|
mailboxId={mailboxId}
|
|
1258
1271
|
selectedMessageIds={Array.from(selectedIds)}
|
|
1272
|
+
selectedSenders={selectedSenders}
|
|
1259
1273
|
junkMailboxId={junkMailboxId}
|
|
1260
1274
|
onClose={() => {
|
|
1261
1275
|
setMobileOrganizeEntry(null);
|
|
@@ -1497,6 +1511,7 @@ export const MessageList = ({
|
|
|
1497
1511
|
accountId={accountId}
|
|
1498
1512
|
mailboxId={mailboxId}
|
|
1499
1513
|
selectedMessageIds={Array.from(selectedIds)}
|
|
1514
|
+
selectedSenders={selectedSenders}
|
|
1500
1515
|
onClose={() => setOrganizeOpen(false)}
|
|
1501
1516
|
/>
|
|
1502
1517
|
)}
|
|
@@ -3,7 +3,7 @@ import { BottomSheet, Button } from "@remit/ui";
|
|
|
3
3
|
import { useQuery } from "@tanstack/react-query";
|
|
4
4
|
import { Loader2 } from "lucide-react";
|
|
5
5
|
import { useEffect, useMemo, useState } from "react";
|
|
6
|
-
import {
|
|
6
|
+
import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
|
|
7
7
|
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
8
8
|
import { buildMoveTargets } from "@/lib/move-targets";
|
|
9
9
|
import {
|
|
@@ -20,6 +20,11 @@ interface MobileOrganizeFlowProps {
|
|
|
20
20
|
accountId: string;
|
|
21
21
|
mailboxId: string;
|
|
22
22
|
selectedMessageIds: string[];
|
|
23
|
+
/**
|
|
24
|
+
* Sender addresses of the selected messages, driving the literal fallback on
|
|
25
|
+
* a deployment without the vector pipeline (semantic-capability.ts).
|
|
26
|
+
*/
|
|
27
|
+
selectedSenders: string[];
|
|
23
28
|
junkMailboxId?: string;
|
|
24
29
|
/** Close the flow and return to the list — dismiss, "Not now", and Done all use it. */
|
|
25
30
|
onClose: () => void;
|
|
@@ -50,6 +55,7 @@ export function MobileOrganizeFlow({
|
|
|
50
55
|
accountId,
|
|
51
56
|
mailboxId,
|
|
52
57
|
selectedMessageIds,
|
|
58
|
+
selectedSenders,
|
|
53
59
|
junkMailboxId,
|
|
54
60
|
onClose,
|
|
55
61
|
}: MobileOrganizeFlowProps) {
|
|
@@ -60,15 +66,16 @@ export function MobileOrganizeFlow({
|
|
|
60
66
|
preview,
|
|
61
67
|
matchedCount,
|
|
62
68
|
semanticUnavailable,
|
|
69
|
+
senders,
|
|
70
|
+
matchPredicate,
|
|
63
71
|
isPending,
|
|
64
72
|
isError,
|
|
65
73
|
error,
|
|
66
|
-
} =
|
|
74
|
+
} = useOrganizeWiden(accountId, anchorMessageId, selectedSenders);
|
|
67
75
|
|
|
68
76
|
useEffect(() => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}, [anchorMessageId, preview]);
|
|
77
|
+
preview();
|
|
78
|
+
}, [preview]);
|
|
72
79
|
|
|
73
80
|
const { data: mailboxesData } = useQuery({
|
|
74
81
|
...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
|
|
@@ -122,12 +129,13 @@ export function MobileOrganizeFlow({
|
|
|
122
129
|
accountId={accountId}
|
|
123
130
|
mailboxId={mailboxId}
|
|
124
131
|
selectedMessageIds={selectedMessageIds}
|
|
125
|
-
|
|
132
|
+
matchPredicate={matchPredicate}
|
|
126
133
|
matchedCount={stage.matchedCount}
|
|
127
134
|
initialScope={seed?.scope}
|
|
128
135
|
seedMailboxId={seed?.moveMailboxId}
|
|
129
136
|
fallback={stage.fallback}
|
|
130
137
|
semanticUnavailable={semanticUnavailable}
|
|
138
|
+
senders={senders}
|
|
131
139
|
onClose={onClose}
|
|
132
140
|
/>
|
|
133
141
|
)}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Button, Dialog } from "@remit/ui";
|
|
2
2
|
import { Loader2 } from "lucide-react";
|
|
3
3
|
import { useEffect } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
|
|
5
5
|
import { OrganizePanel } from "./OrganizePanel";
|
|
6
6
|
|
|
7
7
|
interface OrganizeDialogProps {
|
|
@@ -9,6 +9,11 @@ interface OrganizeDialogProps {
|
|
|
9
9
|
accountId: string;
|
|
10
10
|
mailboxId: string;
|
|
11
11
|
selectedMessageIds: string[];
|
|
12
|
+
/**
|
|
13
|
+
* Sender addresses of the selected messages, driving the literal fallback on
|
|
14
|
+
* a deployment without the vector pipeline (semantic-capability.ts).
|
|
15
|
+
*/
|
|
16
|
+
selectedSenders: string[];
|
|
12
17
|
onClose: () => void;
|
|
13
18
|
}
|
|
14
19
|
|
|
@@ -23,6 +28,7 @@ export function OrganizeDialog({
|
|
|
23
28
|
accountId,
|
|
24
29
|
mailboxId,
|
|
25
30
|
selectedMessageIds,
|
|
31
|
+
selectedSenders,
|
|
26
32
|
onClose,
|
|
27
33
|
}: OrganizeDialogProps) {
|
|
28
34
|
const anchorMessageId = selectedMessageIds[0];
|
|
@@ -31,19 +37,17 @@ export function OrganizeDialog({
|
|
|
31
37
|
reset,
|
|
32
38
|
matchedCount,
|
|
33
39
|
semanticUnavailable,
|
|
40
|
+
senders,
|
|
41
|
+
matchPredicate,
|
|
34
42
|
isPending,
|
|
35
43
|
isError,
|
|
36
44
|
error,
|
|
37
|
-
} =
|
|
45
|
+
} = useOrganizeWiden(accountId, anchorMessageId, selectedSenders);
|
|
38
46
|
|
|
39
47
|
useEffect(() => {
|
|
40
|
-
if (!open
|
|
41
|
-
preview(
|
|
42
|
-
|
|
43
|
-
matchOperator: "And",
|
|
44
|
-
literalClauses: [],
|
|
45
|
-
});
|
|
46
|
-
}, [open, anchorMessageId, preview]);
|
|
48
|
+
if (!open) return;
|
|
49
|
+
preview();
|
|
50
|
+
}, [open, preview]);
|
|
47
51
|
|
|
48
52
|
const handleClose = () => {
|
|
49
53
|
reset();
|
|
@@ -80,9 +84,10 @@ export function OrganizeDialog({
|
|
|
80
84
|
accountId={accountId}
|
|
81
85
|
mailboxId={mailboxId}
|
|
82
86
|
selectedMessageIds={selectedMessageIds}
|
|
83
|
-
|
|
87
|
+
matchPredicate={matchPredicate}
|
|
84
88
|
matchedCount={matchedCount}
|
|
85
89
|
semanticUnavailable={semanticUnavailable}
|
|
90
|
+
senders={senders}
|
|
86
91
|
onClose={handleClose}
|
|
87
92
|
/>
|
|
88
93
|
)}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The back-apply progress copy. On a deployment without the vector pipeline the
|
|
3
|
+
* "all like these" job moves the sender-matched set, so the in-progress line must
|
|
4
|
+
* state the sender semantics — never "similar mail" (#250's honesty
|
|
5
|
+
* requirement). The semantic path keeps the "similar mail" wording.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { afterEach, describe, it } from "node:test";
|
|
10
|
+
import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
11
|
+
import { createElement } from "react";
|
|
12
|
+
import type { OrganizeMatchPredicate } from "../../../lib/organize/sender-fallback";
|
|
13
|
+
import { createDomHarness, type DomHarness } from "../../../test-support/dom";
|
|
14
|
+
import { makeMailbox } from "../../../test-support/fixtures";
|
|
15
|
+
import { type HttpMock, mockFetch } from "../../../test-support/http";
|
|
16
|
+
import { OrganizePanel } from "./OrganizePanel";
|
|
17
|
+
|
|
18
|
+
let harness: DomHarness | undefined;
|
|
19
|
+
let http: HttpMock | undefined;
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
harness?.close();
|
|
23
|
+
harness = undefined;
|
|
24
|
+
http?.restore();
|
|
25
|
+
http = undefined;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const ACCOUNT_ID = "acc-1";
|
|
29
|
+
|
|
30
|
+
const MAILBOXES = [
|
|
31
|
+
makeMailbox({ mailboxId: "mbx-inbox", fullPath: "INBOX" }),
|
|
32
|
+
makeMailbox({ mailboxId: "mbx-archive", fullPath: "Archive" }),
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const SEMANTIC_PREDICATE: OrganizeMatchPredicate = {
|
|
36
|
+
anchorMessageId: "msg-1",
|
|
37
|
+
matchOperator: "And",
|
|
38
|
+
literalClauses: [],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const SENDER_PREDICATE: OrganizeMatchPredicate = {
|
|
42
|
+
matchOperator: "Or",
|
|
43
|
+
literalClauses: [{ field: "From", value: "npm@github.com" }],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const mount = (
|
|
47
|
+
props: Partial<Parameters<typeof OrganizePanel>[0]>,
|
|
48
|
+
): DomHarness => {
|
|
49
|
+
http = mockFetch((call) => {
|
|
50
|
+
if (call.path.endsWith("/organize") && call.method === "POST") {
|
|
51
|
+
return { organizeJobId: "job-1", state: "Processing" };
|
|
52
|
+
}
|
|
53
|
+
if (call.path.endsWith("/organize/job-1") && call.method === "GET") {
|
|
54
|
+
return {
|
|
55
|
+
organizeJobId: "job-1",
|
|
56
|
+
state: "Processing",
|
|
57
|
+
matchedCount: 128,
|
|
58
|
+
appliedCount: 0,
|
|
59
|
+
failedCount: 0,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {};
|
|
63
|
+
});
|
|
64
|
+
harness = createDomHarness();
|
|
65
|
+
harness.queryClient.setQueryData(
|
|
66
|
+
mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT_ID } }),
|
|
67
|
+
{ items: MAILBOXES },
|
|
68
|
+
);
|
|
69
|
+
harness.renderApp(
|
|
70
|
+
createElement(OrganizePanel, {
|
|
71
|
+
accountId: ACCOUNT_ID,
|
|
72
|
+
mailboxId: "mbx-inbox",
|
|
73
|
+
selectedMessageIds: ["msg-1", "msg-2"],
|
|
74
|
+
matchPredicate: SEMANTIC_PREDICATE,
|
|
75
|
+
matchedCount: 128,
|
|
76
|
+
seedMailboxId: "mbx-archive",
|
|
77
|
+
onClose: () => undefined,
|
|
78
|
+
...props,
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
return harness;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
async function startJob(dom: DomHarness): Promise<void> {
|
|
85
|
+
dom.click(dom.byText("button", "Organize"));
|
|
86
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
87
|
+
await dom.flush();
|
|
88
|
+
if (/Organizing/.test(dom.text())) return;
|
|
89
|
+
await dom.wait(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("OrganizePanel back-apply progress copy", () => {
|
|
94
|
+
it("states the sender semantics while organizing in the sender fallback", async () => {
|
|
95
|
+
const dom = mount({
|
|
96
|
+
matchPredicate: SENDER_PREDICATE,
|
|
97
|
+
semanticUnavailable: true,
|
|
98
|
+
senders: ["npm@github.com"],
|
|
99
|
+
});
|
|
100
|
+
await startJob(dom);
|
|
101
|
+
|
|
102
|
+
assert.match(dom.text(), /Organizing mail from these senders/);
|
|
103
|
+
assert.doesNotMatch(dom.text(), /Organizing similar mail/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("keeps the similar-mail wording on the semantic path", async () => {
|
|
107
|
+
const dom = mount({});
|
|
108
|
+
await startJob(dom);
|
|
109
|
+
|
|
110
|
+
assert.match(dom.text(), /Organizing similar mail/);
|
|
111
|
+
assert.doesNotMatch(dom.text(), /from these senders/);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -26,7 +26,11 @@ const render = (overrides: Partial<Parameters<typeof OrganizePanel>[0]> = {}) =>
|
|
|
26
26
|
accountId: "acc-1",
|
|
27
27
|
mailboxId: "mbx-inbox",
|
|
28
28
|
selectedMessageIds: ["msg-1", "msg-2"],
|
|
29
|
-
|
|
29
|
+
matchPredicate: {
|
|
30
|
+
anchorMessageId: "msg-1",
|
|
31
|
+
matchOperator: "And",
|
|
32
|
+
literalClauses: [],
|
|
33
|
+
},
|
|
30
34
|
matchedCount: 47,
|
|
31
35
|
onClose: () => undefined,
|
|
32
36
|
...overrides,
|
|
@@ -62,12 +66,38 @@ describe("OrganizePanel", () => {
|
|
|
62
66
|
assert.match(html, /organizing just your 2 selected/);
|
|
63
67
|
});
|
|
64
68
|
|
|
65
|
-
it("names the missing vector pipeline and organizes just the selection when the widen is unavailable (#226/#201)", () => {
|
|
69
|
+
it("names the missing vector pipeline and organizes just the selection when the widen is unavailable and no senders are known (#226/#201)", () => {
|
|
66
70
|
const html = render({ matchedCount: 0, semanticUnavailable: true });
|
|
67
71
|
assert.match(html, /available on this server/);
|
|
68
72
|
assert.match(html, /organizing just your 2 selected/);
|
|
69
73
|
});
|
|
70
74
|
|
|
75
|
+
it("names the senders and states it is matching all mail from them when the widen fell back (no vector pipeline)", () => {
|
|
76
|
+
const html = render({
|
|
77
|
+
matchedCount: 128,
|
|
78
|
+
semanticUnavailable: true,
|
|
79
|
+
senders: ["npm@github.com", "notifications@github.com"],
|
|
80
|
+
matchPredicate: {
|
|
81
|
+
matchOperator: "Or",
|
|
82
|
+
literalClauses: [
|
|
83
|
+
{ field: "From", value: "npm@github.com" },
|
|
84
|
+
{ field: "From", value: "notifications@github.com" },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
assert.match(html, /matching all mail from/);
|
|
89
|
+
assert.match(html, /npm@github\.com/);
|
|
90
|
+
assert.match(html, /notifications@github\.com/);
|
|
91
|
+
assert.match(html, /128 matches/);
|
|
92
|
+
// Never claims the matched set is semantically similar (only denies that
|
|
93
|
+
// similar-mail matching is available).
|
|
94
|
+
assert.doesNotMatch(html, /similar message/i);
|
|
95
|
+
assert.doesNotMatch(html, /similar ones found/i);
|
|
96
|
+
// The sender fallback is a real widen: the scopes reach it, so the
|
|
97
|
+
// default scope is "all like these", not "just these".
|
|
98
|
+
assert.match(html, /All like these/);
|
|
99
|
+
});
|
|
100
|
+
|
|
71
101
|
it("pre-selects the seeded scope (a 'Something else' shortcut seeds the sentence)", () => {
|
|
72
102
|
const html = render({ initialScope: "standing" });
|
|
73
103
|
// The standing scope's "Always keep" phrasing only renders when it is active.
|
|
@@ -13,17 +13,25 @@ import {
|
|
|
13
13
|
commitButtonLabel,
|
|
14
14
|
commitDisabledReason,
|
|
15
15
|
scopeActionCount,
|
|
16
|
+
senderFallbackSummary,
|
|
16
17
|
} from "@/lib/organize/organize-copy";
|
|
17
18
|
import type {
|
|
18
19
|
OrganizeDraft,
|
|
19
20
|
OrganizeScope,
|
|
20
21
|
} from "@/lib/organize/organize-model";
|
|
22
|
+
import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
21
23
|
|
|
22
24
|
interface OrganizePanelProps {
|
|
23
25
|
accountId: string;
|
|
24
26
|
mailboxId: string;
|
|
25
27
|
selectedMessageIds: string[];
|
|
26
|
-
|
|
28
|
+
/**
|
|
29
|
+
* The predicate the widen previewed — the semantic anchor, or the
|
|
30
|
+
* sender-derived literal clauses when the vector pipeline is absent. Every
|
|
31
|
+
* commit scope carries exactly this, so the previewed set equals the set the
|
|
32
|
+
* scope acts on.
|
|
33
|
+
*/
|
|
34
|
+
matchPredicate: OrganizeMatchPredicate;
|
|
27
35
|
/** Similar messages the widen preview matched. */
|
|
28
36
|
matchedCount: number;
|
|
29
37
|
/**
|
|
@@ -44,10 +52,17 @@ interface OrganizePanelProps {
|
|
|
44
52
|
fallback?: boolean;
|
|
45
53
|
/**
|
|
46
54
|
* The server ran no semantic widen because this deployment ships no vector
|
|
47
|
-
* pipeline.
|
|
48
|
-
*
|
|
55
|
+
* pipeline. With senders to match on ({@link senders}) the widen fell back to
|
|
56
|
+
* matching all mail from those senders; with none it organizes just the
|
|
57
|
+
* selection. Either way the heading names the real reason, never "similar".
|
|
49
58
|
*/
|
|
50
59
|
semanticUnavailable?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Distinct sender addresses driving the sender fallback. Non-empty only when
|
|
62
|
+
* {@link semanticUnavailable} and the fallback produced a literal match set;
|
|
63
|
+
* named in the heading and folder line so the copy states the real semantics.
|
|
64
|
+
*/
|
|
65
|
+
senders?: string[];
|
|
51
66
|
onClose: () => void;
|
|
52
67
|
}
|
|
53
68
|
|
|
@@ -78,17 +93,22 @@ export function OrganizePanel({
|
|
|
78
93
|
accountId,
|
|
79
94
|
mailboxId,
|
|
80
95
|
selectedMessageIds,
|
|
81
|
-
|
|
96
|
+
matchPredicate,
|
|
82
97
|
matchedCount,
|
|
83
98
|
initialScope,
|
|
84
99
|
seedMailboxId,
|
|
85
100
|
fallback = false,
|
|
86
101
|
semanticUnavailable = false,
|
|
102
|
+
senders = [],
|
|
87
103
|
onClose,
|
|
88
104
|
}: OrganizePanelProps) {
|
|
105
|
+
// The vector pipeline is absent but the selection has senders to match on:
|
|
106
|
+
// the widen fell back to sender matching, which produces a real widened set
|
|
107
|
+
// the commit scopes reach — unlike a missing or empty widen.
|
|
108
|
+
const senderFallback = semanticUnavailable && senders.length > 0;
|
|
89
109
|
// A missing widen and an empty widen both organize just the selection; only
|
|
90
|
-
// the heading distinguishes them.
|
|
91
|
-
const noWiden = fallback || semanticUnavailable;
|
|
110
|
+
// the heading distinguishes them. The sender fallback is a real widen.
|
|
111
|
+
const noWiden = (fallback || semanticUnavailable) && !senderFallback;
|
|
92
112
|
const [scope, setScope] = useState<OrganizeScope>(
|
|
93
113
|
initialScope ?? (noWiden ? "just-these" : "all-like-these"),
|
|
94
114
|
);
|
|
@@ -113,14 +133,12 @@ export function OrganizePanel({
|
|
|
113
133
|
|
|
114
134
|
const draft: OrganizeDraft = useMemo(
|
|
115
135
|
() => ({
|
|
116
|
-
|
|
117
|
-
matchOperator: "And",
|
|
118
|
-
literalClauses: [],
|
|
136
|
+
...matchPredicate,
|
|
119
137
|
moveMailboxId: moveMailboxId || undefined,
|
|
120
138
|
expiresAt:
|
|
121
139
|
scope === "temporary" ? pickedDateToExpiresAt(pickedDate) : undefined,
|
|
122
140
|
}),
|
|
123
|
-
[
|
|
141
|
+
[matchPredicate, moveMailboxId, scope, pickedDate],
|
|
124
142
|
);
|
|
125
143
|
|
|
126
144
|
const { moveMessages } = useMoveMessages({ mailboxId, accountId });
|
|
@@ -161,6 +179,7 @@ export function OrganizePanel({
|
|
|
161
179
|
<JobProgress
|
|
162
180
|
progress={organizeJob.progress}
|
|
163
181
|
isDone={organizeJob.isDone}
|
|
182
|
+
senderFallback={senderFallback}
|
|
164
183
|
onClose={onClose}
|
|
165
184
|
/>
|
|
166
185
|
);
|
|
@@ -188,13 +207,17 @@ export function OrganizePanel({
|
|
|
188
207
|
<div className="border-b border-line px-5 py-3">
|
|
189
208
|
<h2 className="text-sm font-semibold text-fg">Organize</h2>
|
|
190
209
|
<p className="mt-0.5 text-xs text-fg-muted">
|
|
191
|
-
{
|
|
192
|
-
?
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
210
|
+
{senderFallback
|
|
211
|
+
? `${senderFallbackSummary(senders)} ${matchedCount} match${
|
|
212
|
+
matchedCount === 1 ? "" : "es"
|
|
213
|
+
}.`
|
|
214
|
+
: semanticUnavailable
|
|
215
|
+
? `Finding similar mail isn't available on this server — organizing just your ${selectionCount} selected.`
|
|
216
|
+
: fallback
|
|
217
|
+
? `No similar messages found — organizing just your ${selectionCount} selected.`
|
|
218
|
+
: `${matchedCount} similar message${matchedCount === 1 ? "" : "s"} found${
|
|
219
|
+
selectionCount > 0 ? ` from ${selectionCount} selected` : ""
|
|
220
|
+
}.`}
|
|
198
221
|
</p>
|
|
199
222
|
</div>
|
|
200
223
|
|
|
@@ -205,7 +228,12 @@ export function OrganizePanel({
|
|
|
205
228
|
<span className="font-semibold text-fg">Always keep </span>
|
|
206
229
|
)}
|
|
207
230
|
{scope === "standing" ? "" : "Keep "}
|
|
208
|
-
{scope === "just-these"
|
|
231
|
+
{scope === "just-these"
|
|
232
|
+
? "these"
|
|
233
|
+
: senderFallback
|
|
234
|
+
? "mail from these senders"
|
|
235
|
+
: "emails like these"}{" "}
|
|
236
|
+
in
|
|
209
237
|
</p>
|
|
210
238
|
<Select
|
|
211
239
|
aria-label="Destination folder"
|
|
@@ -232,6 +260,10 @@ export function OrganizePanel({
|
|
|
232
260
|
<section className="space-y-1.5">
|
|
233
261
|
{SCOPE_OPTIONS.map((option) => {
|
|
234
262
|
const active = scope === option.id;
|
|
263
|
+
const caption =
|
|
264
|
+
senderFallback && option.id === "all-like-these"
|
|
265
|
+
? "including others from these senders"
|
|
266
|
+
: option.caption;
|
|
235
267
|
return (
|
|
236
268
|
<button
|
|
237
269
|
key={option.id}
|
|
@@ -263,7 +295,7 @@ export function OrganizePanel({
|
|
|
263
295
|
{option.label}
|
|
264
296
|
</span>
|
|
265
297
|
<span className="block text-xs text-fg-subtle">
|
|
266
|
-
{
|
|
298
|
+
{caption}
|
|
267
299
|
</span>
|
|
268
300
|
</span>
|
|
269
301
|
</button>
|
|
@@ -330,10 +362,12 @@ export function OrganizePanel({
|
|
|
330
362
|
function JobProgress({
|
|
331
363
|
progress,
|
|
332
364
|
isDone,
|
|
365
|
+
senderFallback,
|
|
333
366
|
onClose,
|
|
334
367
|
}: {
|
|
335
368
|
progress: ReturnType<typeof useOrganizeJob>["progress"];
|
|
336
369
|
isDone: boolean;
|
|
370
|
+
senderFallback: boolean;
|
|
337
371
|
onClose: () => void;
|
|
338
372
|
}) {
|
|
339
373
|
const failed = progress.state === "Failed";
|
|
@@ -350,7 +384,11 @@ function JobProgress({
|
|
|
350
384
|
)}
|
|
351
385
|
|
|
352
386
|
{!isDone && (
|
|
353
|
-
<p className="text-sm font-medium text-fg">
|
|
387
|
+
<p className="text-sm font-medium text-fg">
|
|
388
|
+
{senderFallback
|
|
389
|
+
? "Organizing mail from these senders…"
|
|
390
|
+
: "Organizing similar mail…"}
|
|
391
|
+
</p>
|
|
354
392
|
)}
|
|
355
393
|
|
|
356
394
|
{isDone && !failed && (
|
|
@@ -117,7 +117,11 @@ export const Organize: Story = {
|
|
|
117
117
|
accountId={ACCOUNT_ID}
|
|
118
118
|
mailboxId="mbx-inbox"
|
|
119
119
|
selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
|
|
120
|
-
|
|
120
|
+
matchPredicate={{
|
|
121
|
+
anchorMessageId: "msg-1",
|
|
122
|
+
matchOperator: "And",
|
|
123
|
+
literalClauses: [],
|
|
124
|
+
}}
|
|
121
125
|
matchedCount={47}
|
|
122
126
|
onClose={() => undefined}
|
|
123
127
|
/>
|
|
@@ -137,7 +141,11 @@ export const FromSearch: Story = {
|
|
|
137
141
|
accountId={ACCOUNT_ID}
|
|
138
142
|
mailboxId="mbx-inbox"
|
|
139
143
|
selectedMessageIds={["msg-1"]}
|
|
140
|
-
|
|
144
|
+
matchPredicate={{
|
|
145
|
+
anchorMessageId: "msg-1",
|
|
146
|
+
matchOperator: "And",
|
|
147
|
+
literalClauses: [],
|
|
148
|
+
}}
|
|
141
149
|
matchedCount={412}
|
|
142
150
|
seedMailboxId="mbx-archive"
|
|
143
151
|
onClose={() => undefined}
|
|
@@ -155,7 +163,11 @@ export const AlwaysRule: Story = {
|
|
|
155
163
|
accountId={ACCOUNT_ID}
|
|
156
164
|
mailboxId="mbx-inbox"
|
|
157
165
|
selectedMessageIds={["msg-1", "msg-2"]}
|
|
158
|
-
|
|
166
|
+
matchPredicate={{
|
|
167
|
+
anchorMessageId: "msg-1",
|
|
168
|
+
matchOperator: "And",
|
|
169
|
+
literalClauses: [],
|
|
170
|
+
}}
|
|
159
171
|
matchedCount={47}
|
|
160
172
|
initialScope="standing"
|
|
161
173
|
seedMailboxId="mbx-travel"
|
|
@@ -174,7 +186,11 @@ export const NoSimilarFound: Story = {
|
|
|
174
186
|
accountId={ACCOUNT_ID}
|
|
175
187
|
mailboxId="mbx-inbox"
|
|
176
188
|
selectedMessageIds={["msg-1", "msg-2"]}
|
|
177
|
-
|
|
189
|
+
matchPredicate={{
|
|
190
|
+
anchorMessageId: "msg-1",
|
|
191
|
+
matchOperator: "And",
|
|
192
|
+
literalClauses: [],
|
|
193
|
+
}}
|
|
178
194
|
matchedCount={0}
|
|
179
195
|
fallback
|
|
180
196
|
onClose={() => undefined}
|
|
@@ -196,7 +212,11 @@ export const SemanticUnavailable: Story = {
|
|
|
196
212
|
accountId={ACCOUNT_ID}
|
|
197
213
|
mailboxId="mbx-inbox"
|
|
198
214
|
selectedMessageIds={["msg-1", "msg-2"]}
|
|
199
|
-
|
|
215
|
+
matchPredicate={{
|
|
216
|
+
anchorMessageId: "msg-1",
|
|
217
|
+
matchOperator: "And",
|
|
218
|
+
literalClauses: [],
|
|
219
|
+
}}
|
|
200
220
|
matchedCount={0}
|
|
201
221
|
semanticUnavailable
|
|
202
222
|
onClose={() => undefined}
|
|
@@ -205,6 +225,75 @@ export const SemanticUnavailable: Story = {
|
|
|
205
225
|
),
|
|
206
226
|
};
|
|
207
227
|
|
|
228
|
+
/**
|
|
229
|
+
* No vector pipeline, but the selection has senders to match on — the widen
|
|
230
|
+
* fell back to matching all mail from those senders. The heading names the
|
|
231
|
+
* senders and states the real semantics, and every commit scope (including the
|
|
232
|
+
* standing filter) carries the sender `From` clauses, so it reaches the widened
|
|
233
|
+
* set and keeps working on future mail.
|
|
234
|
+
*/
|
|
235
|
+
export const SenderFallback: Story = {
|
|
236
|
+
name: "Sender Fallback",
|
|
237
|
+
render: () => (
|
|
238
|
+
<SheetStage>
|
|
239
|
+
<OrganizePanel
|
|
240
|
+
accountId={ACCOUNT_ID}
|
|
241
|
+
mailboxId="mbx-inbox"
|
|
242
|
+
selectedMessageIds={["msg-1", "msg-2"]}
|
|
243
|
+
matchPredicate={{
|
|
244
|
+
matchOperator: "Or",
|
|
245
|
+
literalClauses: [
|
|
246
|
+
{ field: "From", value: "npm@github.com" },
|
|
247
|
+
{ field: "From", value: "notifications@github.com" },
|
|
248
|
+
],
|
|
249
|
+
}}
|
|
250
|
+
matchedCount={128}
|
|
251
|
+
semanticUnavailable
|
|
252
|
+
senders={["npm@github.com", "notifications@github.com"]}
|
|
253
|
+
onClose={() => undefined}
|
|
254
|
+
/>
|
|
255
|
+
</SheetStage>
|
|
256
|
+
),
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The sender fallback with a standing rule pre-selected — the "Always keep mail
|
|
261
|
+
* from these senders" sentence, the filter that will match future mail at index
|
|
262
|
+
* time.
|
|
263
|
+
*/
|
|
264
|
+
export const SenderFallbackStanding: Story = {
|
|
265
|
+
name: "Sender Fallback (standing)",
|
|
266
|
+
render: () => (
|
|
267
|
+
<SheetStage>
|
|
268
|
+
<OrganizePanel
|
|
269
|
+
accountId={ACCOUNT_ID}
|
|
270
|
+
mailboxId="mbx-inbox"
|
|
271
|
+
selectedMessageIds={["msg-1", "msg-2", "msg-3", "msg-4"]}
|
|
272
|
+
matchPredicate={{
|
|
273
|
+
matchOperator: "Or",
|
|
274
|
+
literalClauses: [
|
|
275
|
+
{ field: "From", value: "npm@github.com" },
|
|
276
|
+
{ field: "From", value: "notifications@github.com" },
|
|
277
|
+
{ field: "From", value: "noreply@medium.com" },
|
|
278
|
+
{ field: "From", value: "digest@substack.com" },
|
|
279
|
+
],
|
|
280
|
+
}}
|
|
281
|
+
matchedCount={412}
|
|
282
|
+
semanticUnavailable
|
|
283
|
+
senders={[
|
|
284
|
+
"npm@github.com",
|
|
285
|
+
"notifications@github.com",
|
|
286
|
+
"noreply@medium.com",
|
|
287
|
+
"digest@substack.com",
|
|
288
|
+
]}
|
|
289
|
+
initialScope="standing"
|
|
290
|
+
seedMailboxId="mbx-archive"
|
|
291
|
+
onClose={() => undefined}
|
|
292
|
+
/>
|
|
293
|
+
</SheetStage>
|
|
294
|
+
),
|
|
295
|
+
};
|
|
296
|
+
|
|
208
297
|
/** The "Something else" fallback: shortcuts derived from folders + an input. */
|
|
209
298
|
export const SomethingElse: Story = {
|
|
210
299
|
render: () => (
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The widen step's two-phase preview. On a semantic-capable deployment it runs
|
|
3
|
+
* the anchor preview once and stops. On a deployment that reports
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { afterEach, describe, it } from "node:test";
|
|
12
|
+
import { createElement, useEffect } from "react";
|
|
13
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
14
|
+
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
15
|
+
import { useOrganizeWiden } from "./useOrganizeWiden";
|
|
16
|
+
|
|
17
|
+
let harness: DomHarness | undefined;
|
|
18
|
+
let http: HttpMock | undefined;
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
harness?.close();
|
|
22
|
+
harness = undefined;
|
|
23
|
+
http?.restore();
|
|
24
|
+
http = undefined;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
type Widen = ReturnType<typeof useOrganizeWiden>;
|
|
28
|
+
type Responder = Parameters<typeof mockFetch>[0];
|
|
29
|
+
|
|
30
|
+
const PREVIEW_PATH = "/organize/preview";
|
|
31
|
+
|
|
32
|
+
const mount = (senders: string[], responder: Responder) => {
|
|
33
|
+
http = mockFetch(responder);
|
|
34
|
+
harness = createDomHarness();
|
|
35
|
+
const holder: { current: Widen | undefined } = { current: undefined };
|
|
36
|
+
function Probe() {
|
|
37
|
+
const widen = useOrganizeWiden("acc-1", "msg-1", senders);
|
|
38
|
+
holder.current = widen;
|
|
39
|
+
const { preview } = widen;
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
preview();
|
|
42
|
+
}, [preview]);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
harness.renderApp(createElement(Probe));
|
|
46
|
+
return holder;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
async function settle(minCalls: number): Promise<void> {
|
|
50
|
+
const dom = harness;
|
|
51
|
+
const mock = http;
|
|
52
|
+
if (!dom || !mock) throw new Error("not mounted");
|
|
53
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
54
|
+
await dom.flush();
|
|
55
|
+
if (mock.to(PREVIEW_PATH).length >= minCalls) {
|
|
56
|
+
await dom.flush();
|
|
57
|
+
await dom.wait(1);
|
|
58
|
+
await dom.flush();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
await dom.wait(1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("useOrganizeWiden — semantic-capable deployment", () => {
|
|
66
|
+
it("previews the anchor once and reports the semantic match, no fallback", async () => {
|
|
67
|
+
const holder = mount([], () => ({
|
|
68
|
+
matchedCount: 42,
|
|
69
|
+
messageIds: ["a", "b"],
|
|
70
|
+
semanticUnavailable: false,
|
|
71
|
+
}));
|
|
72
|
+
await settle(1);
|
|
73
|
+
|
|
74
|
+
assert.equal(http?.to(PREVIEW_PATH).length, 1);
|
|
75
|
+
const [call] = http?.to(PREVIEW_PATH) ?? [];
|
|
76
|
+
assert.equal(call.body?.anchorMessageId, "msg-1");
|
|
77
|
+
assert.deepEqual(call.body?.literalClauses, []);
|
|
78
|
+
|
|
79
|
+
const widen = holder.current;
|
|
80
|
+
assert.equal(widen?.matchedCount, 42);
|
|
81
|
+
assert.equal(widen?.semanticUnavailable, false);
|
|
82
|
+
assert.equal(widen?.senderFallback, false);
|
|
83
|
+
assert.deepEqual(widen?.matchPredicate, {
|
|
84
|
+
anchorMessageId: "msg-1",
|
|
85
|
+
matchOperator: "And",
|
|
86
|
+
literalClauses: [],
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("useOrganizeWiden — no vector pipeline, senders present", () => {
|
|
92
|
+
it("re-previews with distinct sender From clauses combined with Or and no anchor", async () => {
|
|
93
|
+
const holder = mount(
|
|
94
|
+
["npm@github.com", "npm@github.com", "a@x.com"],
|
|
95
|
+
(call) =>
|
|
96
|
+
call.body?.anchorMessageId
|
|
97
|
+
? { matchedCount: 0, messageIds: [], semanticUnavailable: true }
|
|
98
|
+
: {
|
|
99
|
+
matchedCount: 128,
|
|
100
|
+
messageIds: ["m"],
|
|
101
|
+
semanticUnavailable: false,
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
await settle(2);
|
|
105
|
+
|
|
106
|
+
const calls = http?.to(PREVIEW_PATH) ?? [];
|
|
107
|
+
assert.equal(calls.length, 2);
|
|
108
|
+
// The second preview is the literal fallback: no anchor, Or, distinct
|
|
109
|
+
// sender From clauses.
|
|
110
|
+
const second = calls[1];
|
|
111
|
+
assert.equal(second.body?.anchorMessageId, undefined);
|
|
112
|
+
assert.equal(second.body?.matchOperator, "Or");
|
|
113
|
+
assert.deepEqual(second.body?.literalClauses, [
|
|
114
|
+
{ field: "From", value: "npm@github.com" },
|
|
115
|
+
{ field: "From", value: "a@x.com" },
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
const widen = holder.current;
|
|
119
|
+
assert.equal(widen?.matchedCount, 128);
|
|
120
|
+
assert.equal(widen?.semanticUnavailable, true);
|
|
121
|
+
assert.equal(widen?.senderFallback, true);
|
|
122
|
+
assert.deepEqual(widen?.senders, ["npm@github.com", "a@x.com"]);
|
|
123
|
+
assert.deepEqual(widen?.matchPredicate, {
|
|
124
|
+
matchOperator: "Or",
|
|
125
|
+
literalClauses: [
|
|
126
|
+
{ field: "From", value: "npm@github.com" },
|
|
127
|
+
{ field: "From", value: "a@x.com" },
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("useOrganizeWiden — no vector pipeline, no senders", () => {
|
|
134
|
+
it("does not re-preview and stays capability-absent with the anchor predicate", async () => {
|
|
135
|
+
const holder = mount([], () => ({
|
|
136
|
+
matchedCount: 0,
|
|
137
|
+
messageIds: [],
|
|
138
|
+
semanticUnavailable: true,
|
|
139
|
+
}));
|
|
140
|
+
await settle(1);
|
|
141
|
+
// Give any spurious second preview a chance to fire before asserting none did.
|
|
142
|
+
await harness?.flush();
|
|
143
|
+
|
|
144
|
+
assert.equal(http?.to(PREVIEW_PATH).length, 1);
|
|
145
|
+
const widen = holder.current;
|
|
146
|
+
assert.equal(widen?.semanticUnavailable, true);
|
|
147
|
+
assert.equal(widen?.senderFallback, false);
|
|
148
|
+
assert.deepEqual(widen?.senders, []);
|
|
149
|
+
assert.equal(widen?.matchPredicate.anchorMessageId, "msg-1");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import { useMutation } from "@tanstack/react-query";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { buildOrganizeInput } from "@/lib/organize/organize-model";
|
|
5
|
+
import {
|
|
6
|
+
buildSenderFallbackDraft,
|
|
7
|
+
deriveSenderClauses,
|
|
8
|
+
distinctSenders,
|
|
9
|
+
type OrganizeMatchPredicate,
|
|
10
|
+
} from "@/lib/organize/sender-fallback";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* "Select similar messages" (the widen step) with a sender-derived fallback for
|
|
14
|
+
* deployments without the vector pipeline.
|
|
15
|
+
*
|
|
16
|
+
* The first preview runs the semantic anchor (POST /organize/preview). When the
|
|
17
|
+
* server reports `semanticUnavailable` — no vector pipeline on this deployment
|
|
18
|
+
* (semantic-capability.ts) — the widen re-previews with the literal vocabulary
|
|
19
|
+
* that matches vector-free: one `From` clause per distinct sender in the
|
|
20
|
+
* selection, combined with `Or`, no anchor. The counts shown, the one-time
|
|
21
|
+
* back-apply, and any standing filter then all carry that same literal
|
|
22
|
+
* predicate, so the previewed set equals the set every commit scope acts on.
|
|
23
|
+
*
|
|
24
|
+
* A semantic-capable deployment never fires the second preview and keeps exactly
|
|
25
|
+
* the anchor behaviour.
|
|
26
|
+
*/
|
|
27
|
+
export const useOrganizeWiden = (
|
|
28
|
+
accountId: string | undefined,
|
|
29
|
+
anchorMessageId: string | undefined,
|
|
30
|
+
senders: readonly string[],
|
|
31
|
+
) => {
|
|
32
|
+
const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
|
|
33
|
+
const { mutate, reset: mutationReset } = mutation;
|
|
34
|
+
const [fellBack, setFellBack] = useState(false);
|
|
35
|
+
|
|
36
|
+
const distinct = useMemo(() => distinctSenders(senders), [senders]);
|
|
37
|
+
|
|
38
|
+
const preview = useCallback(() => {
|
|
39
|
+
if (!accountId || !anchorMessageId) return;
|
|
40
|
+
setFellBack(false);
|
|
41
|
+
mutate({
|
|
42
|
+
path: { accountId },
|
|
43
|
+
body: buildOrganizeInput({
|
|
44
|
+
anchorMessageId,
|
|
45
|
+
matchOperator: "And",
|
|
46
|
+
literalClauses: [],
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
}, [accountId, anchorMessageId, mutate]);
|
|
50
|
+
|
|
51
|
+
const reset = useCallback(() => {
|
|
52
|
+
setFellBack(false);
|
|
53
|
+
mutationReset();
|
|
54
|
+
}, [mutationReset]);
|
|
55
|
+
|
|
56
|
+
const data = mutation.data;
|
|
57
|
+
// The first preview came back capability-absent and there are senders to
|
|
58
|
+
// match on: re-preview with the literal clauses before showing any count, so
|
|
59
|
+
// the count the user sees is the sender-match count, never a flash of the
|
|
60
|
+
// empty semantic result.
|
|
61
|
+
const willFallBack =
|
|
62
|
+
data?.semanticUnavailable === true && !fellBack && distinct.length > 0;
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!willFallBack || !accountId) return;
|
|
66
|
+
setFellBack(true);
|
|
67
|
+
mutate({
|
|
68
|
+
path: { accountId },
|
|
69
|
+
body: buildOrganizeInput(buildSenderFallbackDraft(distinct)),
|
|
70
|
+
});
|
|
71
|
+
}, [willFallBack, accountId, distinct, mutate]);
|
|
72
|
+
|
|
73
|
+
// The literal re-preview has no anchor, so its response reports
|
|
74
|
+
// `semanticUnavailable: false`; `fellBack` is what remembers the capability
|
|
75
|
+
// was absent once the fallback has run.
|
|
76
|
+
const capabilityAbsent = fellBack || (data?.semanticUnavailable ?? false);
|
|
77
|
+
const isPending = mutation.isPending || willFallBack;
|
|
78
|
+
|
|
79
|
+
const matchPredicate: OrganizeMatchPredicate = fellBack
|
|
80
|
+
? { matchOperator: "Or", literalClauses: deriveSenderClauses(distinct) }
|
|
81
|
+
: {
|
|
82
|
+
...(anchorMessageId ? { anchorMessageId } : {}),
|
|
83
|
+
matchOperator: "And",
|
|
84
|
+
literalClauses: [],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
preview,
|
|
89
|
+
reset,
|
|
90
|
+
matchedCount: isPending ? undefined : data?.matchedCount,
|
|
91
|
+
messageIds: isPending ? undefined : data?.messageIds,
|
|
92
|
+
// The deployment ships no vector pipeline — the widen matched on senders
|
|
93
|
+
// (or, with no senders, on nothing) rather than semantic similarity.
|
|
94
|
+
semanticUnavailable: capabilityAbsent,
|
|
95
|
+
// The widen fell back to sender matching and produced a usable literal
|
|
96
|
+
// match set: the commit scopes reach it, and the copy names the senders.
|
|
97
|
+
senderFallback: fellBack,
|
|
98
|
+
senders: distinct,
|
|
99
|
+
matchPredicate,
|
|
100
|
+
isPending,
|
|
101
|
+
isError: mutation.isError,
|
|
102
|
+
error: mutation.error,
|
|
103
|
+
};
|
|
104
|
+
};
|
|
@@ -3,7 +3,9 @@ import { describe, it } from "node:test";
|
|
|
3
3
|
import {
|
|
4
4
|
commitButtonLabel,
|
|
5
5
|
commitDisabledReason,
|
|
6
|
+
formatSenderList,
|
|
6
7
|
scopeActionCount,
|
|
8
|
+
senderFallbackSummary,
|
|
7
9
|
} from "./organize-copy";
|
|
8
10
|
import type { OrganizeDraft } from "./organize-model";
|
|
9
11
|
|
|
@@ -91,3 +93,42 @@ describe("scopeActionCount", () => {
|
|
|
91
93
|
assert.equal(scopeActionCount("standing", 3, 48), 48);
|
|
92
94
|
});
|
|
93
95
|
});
|
|
96
|
+
|
|
97
|
+
describe("formatSenderList", () => {
|
|
98
|
+
it("names a single sender bare", () => {
|
|
99
|
+
assert.equal(formatSenderList(["npm@github.com"]), "npm@github.com");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("joins a couple with 'and'", () => {
|
|
103
|
+
assert.equal(
|
|
104
|
+
formatSenderList(["a@x.com", "b@y.com"]),
|
|
105
|
+
"a@x.com and b@y.com",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("lists up to three in full", () => {
|
|
110
|
+
assert.equal(
|
|
111
|
+
formatSenderList(["a@x.com", "b@y.com", "c@z.com"]),
|
|
112
|
+
"a@x.com, b@y.com and c@z.com",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("truncates past three, summing the rest", () => {
|
|
117
|
+
assert.equal(
|
|
118
|
+
formatSenderList(["a@x.com", "b@y.com", "c@z.com", "d@w.com"]),
|
|
119
|
+
"a@x.com, b@y.com, c@z.com and 1 other",
|
|
120
|
+
);
|
|
121
|
+
assert.equal(
|
|
122
|
+
formatSenderList(["a@x.com", "b@y.com", "c@z.com", "d@w.com", "e@v.com"]),
|
|
123
|
+
"a@x.com, b@y.com, c@z.com and 2 others",
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("senderFallbackSummary", () => {
|
|
129
|
+
it("states it is matching all mail from the senders, never 'similar'", () => {
|
|
130
|
+
const summary = senderFallbackSummary(["npm@github.com"]);
|
|
131
|
+
assert.match(summary, /isn't available on this server/);
|
|
132
|
+
assert.match(summary, /matching all mail from npm@github\.com instead/);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -59,3 +59,32 @@ export const scopeActionCount = (
|
|
|
59
59
|
selectionCount: number,
|
|
60
60
|
matchedCount: number,
|
|
61
61
|
): number => (scope === "just-these" ? selectionCount : matchedCount);
|
|
62
|
+
|
|
63
|
+
/** How many sender addresses are named before the rest are summed as "N others". */
|
|
64
|
+
const MAX_SENDERS_SHOWN = 3;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The sender addresses read out in prose: all of them when there are a few, or
|
|
68
|
+
* the first {@link MAX_SENDERS_SHOWN} and a count of the rest so a long selection
|
|
69
|
+
* stays legible in the sheet.
|
|
70
|
+
*/
|
|
71
|
+
export const formatSenderList = (senders: readonly string[]): string => {
|
|
72
|
+
if (senders.length === 0) return "these senders";
|
|
73
|
+
if (senders.length === 1) return senders[0];
|
|
74
|
+
if (senders.length <= MAX_SENDERS_SHOWN) {
|
|
75
|
+
return `${senders.slice(0, -1).join(", ")} and ${senders[senders.length - 1]}`;
|
|
76
|
+
}
|
|
77
|
+
const shown = senders.slice(0, MAX_SENDERS_SHOWN);
|
|
78
|
+
const rest = senders.length - MAX_SENDERS_SHOWN;
|
|
79
|
+
return `${shown.join(", ")} and ${rest} other${rest === 1 ? "" : "s"}`;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The heading when the widen fell back to sender matching (no vector pipeline on
|
|
84
|
+
* this server). States the actual semantics — matching every mail from these
|
|
85
|
+
* senders — and never claims semantic similarity.
|
|
86
|
+
*/
|
|
87
|
+
export const senderFallbackSummary = (senders: readonly string[]): string =>
|
|
88
|
+
`Similar-mail matching isn't available on this server — matching all mail from ${formatSenderList(
|
|
89
|
+
senders,
|
|
90
|
+
)} instead.`;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
buildSenderFallbackDraft,
|
|
5
|
+
deriveSenderClauses,
|
|
6
|
+
distinctSenders,
|
|
7
|
+
} from "./sender-fallback";
|
|
8
|
+
|
|
9
|
+
describe("distinctSenders", () => {
|
|
10
|
+
it("drops empties and blanks, trimming what remains", () => {
|
|
11
|
+
assert.deepEqual(
|
|
12
|
+
distinctSenders([" npm@github.com ", "", " ", "a@x.com"]),
|
|
13
|
+
["npm@github.com", "a@x.com"],
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("de-duplicates case-insensitively, keeping first-seen casing and order", () => {
|
|
18
|
+
assert.deepEqual(
|
|
19
|
+
distinctSenders([
|
|
20
|
+
"NPM@github.com",
|
|
21
|
+
"a@x.com",
|
|
22
|
+
"npm@GITHUB.com",
|
|
23
|
+
"a@x.com",
|
|
24
|
+
]),
|
|
25
|
+
["NPM@github.com", "a@x.com"],
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("deriveSenderClauses", () => {
|
|
31
|
+
it("emits one From clause per distinct sender", () => {
|
|
32
|
+
assert.deepEqual(
|
|
33
|
+
deriveSenderClauses(["npm@github.com", "npm@github.com", "a@x.com"]),
|
|
34
|
+
[
|
|
35
|
+
{ field: "From", value: "npm@github.com" },
|
|
36
|
+
{ field: "From", value: "a@x.com" },
|
|
37
|
+
],
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("is empty when no sender survives", () => {
|
|
42
|
+
assert.deepEqual(deriveSenderClauses(["", " "]), []);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("buildSenderFallbackDraft", () => {
|
|
47
|
+
it("combines the sender clauses with Or and carries no anchor", () => {
|
|
48
|
+
const draft = buildSenderFallbackDraft(["npm@github.com", "a@x.com"]);
|
|
49
|
+
assert.equal(draft.matchOperator, "Or");
|
|
50
|
+
assert.equal(draft.anchorMessageId, undefined);
|
|
51
|
+
assert.deepEqual(draft.literalClauses, [
|
|
52
|
+
{ field: "From", value: "npm@github.com" },
|
|
53
|
+
{ field: "From", value: "a@x.com" },
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RemitImapFilterClause,
|
|
3
|
+
RemitImapOrganizeInput,
|
|
4
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
+
import type { OrganizeDraft } from "./organize-model";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The widen fallback for a deployment that ships no vector pipeline (self-host
|
|
9
|
+
* sqlite — semantic-capability.ts). The semantic anchor matches nothing there,
|
|
10
|
+
* so a widen degrades to the literal vocabulary RFC 031 already matches
|
|
11
|
+
* vector-free: one `From` clause per distinct sender in the selection, combined
|
|
12
|
+
* with `Or`, no anchor. The same predicate matches at index time (RFC 034), so a
|
|
13
|
+
* standing filter built from it keeps working on future mail.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Distinct sender addresses from the selection, trimmed, empties dropped, and
|
|
18
|
+
* de-duplicated case-insensitively while preserving first-seen casing and order.
|
|
19
|
+
*/
|
|
20
|
+
export const distinctSenders = (senders: readonly string[]): string[] => {
|
|
21
|
+
const seen = new Set<string>();
|
|
22
|
+
const out: string[] = [];
|
|
23
|
+
for (const raw of senders) {
|
|
24
|
+
const value = raw.trim();
|
|
25
|
+
if (value === "") continue;
|
|
26
|
+
const key = value.toLowerCase();
|
|
27
|
+
if (seen.has(key)) continue;
|
|
28
|
+
seen.add(key);
|
|
29
|
+
out.push(value);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One `From` literal clause per distinct sender address. A `From` clause matches
|
|
36
|
+
* the sender address or display name (match.ts `clauseMatches`), so the address
|
|
37
|
+
* is the precise, stable key.
|
|
38
|
+
*/
|
|
39
|
+
export const deriveSenderClauses = (
|
|
40
|
+
senders: readonly string[],
|
|
41
|
+
): RemitImapFilterClause[] =>
|
|
42
|
+
distinctSenders(senders).map((value) => ({ field: "From", value }));
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The literal predicate that stands in for the semantic anchor: the sender `From`
|
|
46
|
+
* clauses combined with `Or` and no anchor. The preview, the one-time back-apply,
|
|
47
|
+
* and the standing filter all carry exactly this.
|
|
48
|
+
*/
|
|
49
|
+
export const buildSenderFallbackDraft = (
|
|
50
|
+
senders: readonly string[],
|
|
51
|
+
): OrganizeDraft => ({
|
|
52
|
+
matchOperator: "Or",
|
|
53
|
+
literalClauses: deriveSenderClauses(senders),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The predicate the widen previewed, handed to the organize sentence so the set
|
|
58
|
+
* it previews equals the set every commit scope acts on. Either the semantic
|
|
59
|
+
* anchor or the sender-derived literal fallback, never both.
|
|
60
|
+
*/
|
|
61
|
+
export type OrganizeMatchPredicate = Pick<
|
|
62
|
+
RemitImapOrganizeInput,
|
|
63
|
+
"anchorMessageId" | "matchOperator" | "literalClauses"
|
|
64
|
+
>;
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import { useMutation } from "@tanstack/react-query";
|
|
3
|
-
import { useCallback } from "react";
|
|
4
|
-
import {
|
|
5
|
-
buildOrganizeInput,
|
|
6
|
-
type OrganizeDraft,
|
|
7
|
-
} from "@/lib/organize/organize-model";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* "Select similar messages" (the widen step). Runs the read-only matcher once
|
|
11
|
-
* server-side (POST /organize/preview) and returns the messages the same
|
|
12
|
-
* predicate would apply an action to — the previewed set equals the set a job
|
|
13
|
-
* with the same input would apply to. This is the only corpus-wide query the
|
|
14
|
-
* flow makes; there is no client-side pagination over messages.
|
|
15
|
-
*/
|
|
16
|
-
export const useOrganizePreview = (accountId: string | undefined) => {
|
|
17
|
-
const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
|
|
18
|
-
const { mutate } = mutation;
|
|
19
|
-
|
|
20
|
-
const preview = useCallback(
|
|
21
|
-
(draft: OrganizeDraft) => {
|
|
22
|
-
if (!accountId) return;
|
|
23
|
-
mutate({
|
|
24
|
-
path: { accountId },
|
|
25
|
-
body: buildOrganizeInput(draft),
|
|
26
|
-
});
|
|
27
|
-
},
|
|
28
|
-
[accountId, mutate],
|
|
29
|
-
);
|
|
30
|
-
|
|
31
|
-
return {
|
|
32
|
-
preview,
|
|
33
|
-
reset: mutation.reset,
|
|
34
|
-
matchedCount: mutation.data?.matchedCount,
|
|
35
|
-
messageIds: mutation.data?.messageIds,
|
|
36
|
-
// True when the server ran no semantic widen because this deployment
|
|
37
|
-
// ships no vector pipeline — the matched set is literal-only (or empty).
|
|
38
|
-
semanticUnavailable: mutation.data?.semanticUnavailable ?? false,
|
|
39
|
-
isPending: mutation.isPending,
|
|
40
|
-
isError: mutation.isError,
|
|
41
|
-
error: mutation.error,
|
|
42
|
-
};
|
|
43
|
-
};
|