@remit/web-client 0.0.236 → 0.0.238
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/IntelligencePane.test.ts +34 -0
- package/src/components/mail/IntelligencePane.tsx +15 -1
- package/src/components/mail/SelectionWizardHost.tsx +8 -1
- package/src/components/mail/intelligence-semantic-off.render.test.ts +128 -0
- package/src/hooks/useIntelligenceData.ts +16 -1
- package/src/hooks/useSemanticSearchEnabled.ts +23 -0
- package/src/index.css +7 -0
- package/src/test-support/fixtures.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.238",
|
|
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": {
|
|
@@ -36,6 +36,7 @@ describe("resolveSimilarState — fail-fast on similar-messages search", () => {
|
|
|
36
36
|
similarError: new Error("500"),
|
|
37
37
|
similarErrorIsFatal: true,
|
|
38
38
|
isSimilarLoading: false,
|
|
39
|
+
semanticEnabled: true,
|
|
39
40
|
}),
|
|
40
41
|
"ready",
|
|
41
42
|
);
|
|
@@ -47,6 +48,7 @@ describe("resolveSimilarState — fail-fast on similar-messages search", () => {
|
|
|
47
48
|
similarError: new Error("404"),
|
|
48
49
|
similarErrorIsFatal: false,
|
|
49
50
|
isSimilarLoading: false,
|
|
51
|
+
semanticEnabled: true,
|
|
50
52
|
}),
|
|
51
53
|
"error",
|
|
52
54
|
);
|
|
@@ -58,6 +60,7 @@ describe("resolveSimilarState — fail-fast on similar-messages search", () => {
|
|
|
58
60
|
similarError: null,
|
|
59
61
|
similarErrorIsFatal: false,
|
|
60
62
|
isSimilarLoading: false,
|
|
63
|
+
semanticEnabled: true,
|
|
61
64
|
}),
|
|
62
65
|
"ready",
|
|
63
66
|
);
|
|
@@ -69,8 +72,39 @@ describe("resolveSimilarState — fail-fast on similar-messages search", () => {
|
|
|
69
72
|
similarError: null,
|
|
70
73
|
similarErrorIsFatal: false,
|
|
71
74
|
isSimilarLoading: true,
|
|
75
|
+
semanticEnabled: true,
|
|
72
76
|
}),
|
|
73
77
|
"loading",
|
|
74
78
|
);
|
|
75
79
|
});
|
|
76
80
|
});
|
|
81
|
+
|
|
82
|
+
// Off is a property of the deployment, and it leads: nothing was embedded, so
|
|
83
|
+
// there was no query, and the section states the setting instead of an empty
|
|
84
|
+
// list (#1068). `undefined` is the config read still in flight, which is not
|
|
85
|
+
// an answer.
|
|
86
|
+
describe("resolveSimilarState — semantic search off on this instance", () => {
|
|
87
|
+
test("off wins over every runtime state", () => {
|
|
88
|
+
assert.equal(
|
|
89
|
+
resolveSimilarState({
|
|
90
|
+
similarError: new Error("404"),
|
|
91
|
+
similarErrorIsFatal: false,
|
|
92
|
+
isSimilarLoading: true,
|
|
93
|
+
semanticEnabled: false,
|
|
94
|
+
}),
|
|
95
|
+
"off",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a config read still in flight is not an off state", () => {
|
|
100
|
+
assert.equal(
|
|
101
|
+
resolveSimilarState({
|
|
102
|
+
similarError: null,
|
|
103
|
+
similarErrorIsFatal: false,
|
|
104
|
+
isSimilarLoading: false,
|
|
105
|
+
semanticEnabled: undefined,
|
|
106
|
+
}),
|
|
107
|
+
"ready",
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -82,6 +82,11 @@ export const resolveSpamAction = (thread: {
|
|
|
82
82
|
/**
|
|
83
83
|
* Decide the "Similar messages" section state from the semantic-search query.
|
|
84
84
|
*
|
|
85
|
+
* An instance with semantic search off leads, before any error or in-flight
|
|
86
|
+
* state: nothing is embedded, no query was sent, and the section states the
|
|
87
|
+
* setting (#1068). Only `false` counts — the config read has not answered yet
|
|
88
|
+
* while it is `undefined`, and an off state shown then would be a guess.
|
|
89
|
+
*
|
|
85
90
|
* The fail-fast rule: a fatal first-party 5xx must NEVER degrade to the benign
|
|
86
91
|
* grey "Similarity search unavailable" label — it escalates to the global red
|
|
87
92
|
* overlay instead, so this returns `"ready"` for it (the section then renders
|
|
@@ -93,8 +98,15 @@ export const resolveSimilarState = (input: {
|
|
|
93
98
|
similarError: unknown;
|
|
94
99
|
similarErrorIsFatal: boolean;
|
|
95
100
|
isSimilarLoading: boolean;
|
|
101
|
+
semanticEnabled: boolean | undefined;
|
|
96
102
|
}): SimilarState => {
|
|
97
|
-
const {
|
|
103
|
+
const {
|
|
104
|
+
similarError,
|
|
105
|
+
similarErrorIsFatal,
|
|
106
|
+
isSimilarLoading,
|
|
107
|
+
semanticEnabled,
|
|
108
|
+
} = input;
|
|
109
|
+
if (semanticEnabled === false) return "off";
|
|
98
110
|
if (similarError && !similarErrorIsFatal) return "error";
|
|
99
111
|
if (isSimilarLoading) return "loading";
|
|
100
112
|
return "ready";
|
|
@@ -228,6 +240,7 @@ function WiredPanel({
|
|
|
228
240
|
isSimilarLoading,
|
|
229
241
|
similarError,
|
|
230
242
|
similarErrorIsFatal,
|
|
243
|
+
semanticEnabled,
|
|
231
244
|
} = useIntelligenceData(thread, mailboxId);
|
|
232
245
|
const [reclassifyOpen, setReclassifyOpen] = useState(false);
|
|
233
246
|
const senderEmail = thread.fromEmail ?? undefined;
|
|
@@ -336,6 +349,7 @@ function WiredPanel({
|
|
|
336
349
|
similarError,
|
|
337
350
|
similarErrorIsFatal,
|
|
338
351
|
isSimilarLoading,
|
|
352
|
+
semanticEnabled,
|
|
339
353
|
});
|
|
340
354
|
|
|
341
355
|
return (
|
|
@@ -49,6 +49,7 @@ import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
|
49
49
|
import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
|
|
50
50
|
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
51
51
|
import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
|
|
52
|
+
import { useSemanticSearchEnabled } from "@/hooks/useSemanticSearchEnabled";
|
|
52
53
|
import type {
|
|
53
54
|
BulkActionProgress,
|
|
54
55
|
BulkActionTarget,
|
|
@@ -563,7 +564,12 @@ function SelectionWizardSession({
|
|
|
563
564
|
// unavailable while the user is already holding it. The fallback is taken then
|
|
564
565
|
// rather than left as a door that counts nothing: the property step says, in
|
|
565
566
|
// so many words, that these are the senders it substituted (#477 3.6).
|
|
566
|
-
|
|
567
|
+
// Off is a deployment setting, not a probe result, and it reaches the door the
|
|
568
|
+
// same way the runtime failure does: dimmed, pressable, and falling back to
|
|
569
|
+
// the senders. Only the copy differs (#1068).
|
|
570
|
+
const semanticOff = useSemanticSearchEnabled() === false;
|
|
571
|
+
const semanticUnavailable =
|
|
572
|
+
semanticOff || widen.semanticUnavailable || widen.isError;
|
|
567
573
|
// Only while the door is the screen: the probe re-fires when the anchor
|
|
568
574
|
// changes under a background refetch, and a late answer that moved the step
|
|
569
575
|
// from Review would push an entry the wizard does not own and leave the count
|
|
@@ -1001,6 +1007,7 @@ function SelectionWizardSession({
|
|
|
1001
1007
|
accountId: wizardScope.accountId,
|
|
1002
1008
|
onModeChange: changeMode,
|
|
1003
1009
|
semanticUnavailable,
|
|
1010
|
+
semanticOff,
|
|
1004
1011
|
semanticErrorDetail:
|
|
1005
1012
|
widen.error instanceof Error ? widen.error.message : undefined,
|
|
1006
1013
|
semanticFallbackTaken,
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Similar messages" on an instance with semantic search off (#1068).
|
|
3
|
+
*
|
|
4
|
+
* Off is a deployment setting, and the client learns it from the config read
|
|
5
|
+
* every surface already makes. Before this the section rendered nothing at all,
|
|
6
|
+
* which reads as a sender nothing resembles — the one thing an operator who
|
|
7
|
+
* has not turned semantic search on cannot tell from the truth.
|
|
8
|
+
*
|
|
9
|
+
* Drives the real chain: `useIntelligenceData` reads `GET /config`, the state
|
|
10
|
+
* resolver turns `semanticSearchEnabled: false` into the off state, and the
|
|
11
|
+
* real `IntelligencePanel` renders it. Both halves are asserted — the copy the
|
|
12
|
+
* operator acts on, and that no semantic query is sent at all.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { afterEach, describe, it } from "node:test";
|
|
17
|
+
import { IntelligencePanel } from "@remit/ui";
|
|
18
|
+
import { createElement } from "react";
|
|
19
|
+
import { useIntelligenceData } from "@/hooks/useIntelligenceData";
|
|
20
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
21
|
+
import {
|
|
22
|
+
makeAccount,
|
|
23
|
+
makeConfig,
|
|
24
|
+
makeThreadMessage,
|
|
25
|
+
} from "@/test-support/fixtures";
|
|
26
|
+
import { type HttpMock, mockFetch } from "@/test-support/http";
|
|
27
|
+
import { resolveSimilarState } from "./IntelligencePane";
|
|
28
|
+
|
|
29
|
+
const thread = makeThreadMessage({ messageId: "msg-1" });
|
|
30
|
+
|
|
31
|
+
const SimilarHarness = () => {
|
|
32
|
+
const {
|
|
33
|
+
data,
|
|
34
|
+
isSimilarLoading,
|
|
35
|
+
similarError,
|
|
36
|
+
similarErrorIsFatal,
|
|
37
|
+
semanticEnabled,
|
|
38
|
+
} = useIntelligenceData(thread, "mbx-inbox");
|
|
39
|
+
if (!data) return null;
|
|
40
|
+
return createElement(IntelligencePanel, {
|
|
41
|
+
data,
|
|
42
|
+
similarState: resolveSimilarState({
|
|
43
|
+
similarError,
|
|
44
|
+
similarErrorIsFatal,
|
|
45
|
+
isSimilarLoading,
|
|
46
|
+
semanticEnabled,
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const respond =
|
|
52
|
+
(semanticSearchEnabled: boolean) => (call: { path: string }) => {
|
|
53
|
+
if (call.path.endsWith("/config")) {
|
|
54
|
+
return makeConfig([makeAccount({ accountId: "acc-1" })], {
|
|
55
|
+
semanticSearchEnabled,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (call.path.endsWith("/addresses/search")) return { items: [] };
|
|
59
|
+
return { items: [] };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
let harness: DomHarness | undefined;
|
|
63
|
+
let http: HttpMock;
|
|
64
|
+
|
|
65
|
+
const waitFor = async (
|
|
66
|
+
predicate: () => boolean,
|
|
67
|
+
timeoutMs = 2000,
|
|
68
|
+
): Promise<void> => {
|
|
69
|
+
if (!harness) throw new Error("nothing mounted");
|
|
70
|
+
const deadline = Date.now() + timeoutMs;
|
|
71
|
+
while (!predicate()) {
|
|
72
|
+
if (Date.now() > deadline) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`waitFor: condition never became true within ${timeoutMs}ms`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
await harness.flush();
|
|
78
|
+
await harness.wait(5);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
harness?.close();
|
|
84
|
+
harness = undefined;
|
|
85
|
+
http.restore();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("the Similar messages section on an instance with semantic search off", () => {
|
|
89
|
+
it("states the setting, what turning it on buys, and the command", async () => {
|
|
90
|
+
http = mockFetch(respond(false));
|
|
91
|
+
harness = createDomHarness();
|
|
92
|
+
harness.renderApp(createElement(SimilarHarness));
|
|
93
|
+
|
|
94
|
+
await waitFor(
|
|
95
|
+
() => harness?.text().includes("Semantic search is off") === true,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const text = harness.text();
|
|
99
|
+
assert.match(text, /Semantic search is off on this instance/);
|
|
100
|
+
assert.match(text, /Organize/);
|
|
101
|
+
assert.match(text, /filters/);
|
|
102
|
+
assert.match(text, /remit semantic on/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("sends no semantic query, because an empty answer is the same as no match", async () => {
|
|
106
|
+
http = mockFetch(respond(false));
|
|
107
|
+
harness = createDomHarness();
|
|
108
|
+
harness.renderApp(createElement(SimilarHarness));
|
|
109
|
+
|
|
110
|
+
await waitFor(
|
|
111
|
+
() => harness?.text().includes("Semantic search is off") === true,
|
|
112
|
+
);
|
|
113
|
+
await harness.wait(30);
|
|
114
|
+
await harness.flush();
|
|
115
|
+
|
|
116
|
+
assert.deepEqual(http.to("/search/semantic"), []);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("says none of it while the instance has semantic search on", async () => {
|
|
120
|
+
http = mockFetch(respond(true));
|
|
121
|
+
harness = createDomHarness();
|
|
122
|
+
harness.renderApp(createElement(SimilarHarness));
|
|
123
|
+
|
|
124
|
+
await waitFor(() => http.to("/search/semantic").length > 0);
|
|
125
|
+
|
|
126
|
+
assert.doesNotMatch(harness.text(), /Semantic search is off/);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
import { isThreadCategory } from "@remit/ui";
|
|
19
19
|
import { useQuery } from "@tanstack/react-query";
|
|
20
20
|
import { useMemo } from "react";
|
|
21
|
+
import { useSemanticSearchEnabled } from "@/hooks/useSemanticSearchEnabled";
|
|
21
22
|
import { isServerError } from "@/lib/error-classifier";
|
|
22
23
|
import { formatDate, formatEmailDate } from "@/lib/format";
|
|
23
24
|
import {
|
|
@@ -291,6 +292,12 @@ export interface UseIntelligenceDataResult {
|
|
|
291
292
|
* error or an empty result keeps the panel quiet.
|
|
292
293
|
*/
|
|
293
294
|
similarErrorIsFatal: boolean;
|
|
295
|
+
/**
|
|
296
|
+
* False on an instance with semantic search off (#1068): nothing is embedded,
|
|
297
|
+
* so the query is never sent and the section states the setting instead of
|
|
298
|
+
* rendering an empty list. `undefined` until the config read answers.
|
|
299
|
+
*/
|
|
300
|
+
semanticEnabled: boolean | undefined;
|
|
294
301
|
/**
|
|
295
302
|
* True when the address lookup failed with a fatal first-party 5xx. The panel
|
|
296
303
|
* must not silently render the sender's flags (VIP/muted/blocked) as absent —
|
|
@@ -342,6 +349,7 @@ export function useIntelligenceData(
|
|
|
342
349
|
);
|
|
343
350
|
|
|
344
351
|
// --- Semantic search: similar messages ---
|
|
352
|
+
const semanticEnabled = useSemanticSearchEnabled();
|
|
345
353
|
const semanticQuery = [subject, senderEmail].filter(Boolean).join(" ");
|
|
346
354
|
const {
|
|
347
355
|
data: semanticResult,
|
|
@@ -351,7 +359,13 @@ export function useIntelligenceData(
|
|
|
351
359
|
...semanticSearchOperationsSemanticSearchOptions({
|
|
352
360
|
query: { query: semanticQuery, limit: 5 },
|
|
353
361
|
}),
|
|
354
|
-
|
|
362
|
+
// Waits for the config read rather than racing it. An off instance answers
|
|
363
|
+
// this query with an empty list, which the panel cannot tell from a sender
|
|
364
|
+
// nothing resembles, so the request is not worth sending on the chance the
|
|
365
|
+
// instance turns out to be on. `undefined` is that read still in flight;
|
|
366
|
+
// it is cached process-wide and every other surface already made it.
|
|
367
|
+
enabled:
|
|
368
|
+
Boolean(thread && semanticQuery.length > 3) && semanticEnabled === true,
|
|
355
369
|
staleTime: 60_000,
|
|
356
370
|
// A non-fatal failure (e.g. 404, no index yet) is acceptable — the sidebar
|
|
357
371
|
// degrades gracefully. A 5xx is NOT acceptable: it escalates globally and
|
|
@@ -397,6 +411,7 @@ export function useIntelligenceData(
|
|
|
397
411
|
isSimilarLoading,
|
|
398
412
|
similarError,
|
|
399
413
|
similarErrorIsFatal: isServerError(similarError),
|
|
414
|
+
semanticEnabled,
|
|
400
415
|
addressErrorIsFatal: isServerError(addressError),
|
|
401
416
|
addressId: address?.addressId,
|
|
402
417
|
address,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether this instance stores message vectors (#1068).
|
|
6
|
+
*
|
|
7
|
+
* A property of the deployment, so it rides the config read every surface
|
|
8
|
+
* already makes rather than a probe of its own: the semantic surfaces need the
|
|
9
|
+
* answer before they have a query to send, and an empty semantic result is
|
|
10
|
+
* indistinguishable from a mailbox with nothing similar in it.
|
|
11
|
+
*
|
|
12
|
+
* `undefined` while the config is in flight, and for a server that predates the
|
|
13
|
+
* field. Callers must therefore branch on `=== false`, so an instance whose
|
|
14
|
+
* answer has not arrived shows what it showed before rather than an off state
|
|
15
|
+
* it may not be in.
|
|
16
|
+
*/
|
|
17
|
+
export const useSemanticSearchEnabled = (): boolean | undefined => {
|
|
18
|
+
const { data } = useQuery({
|
|
19
|
+
...configOperationsGetConfigOptions(),
|
|
20
|
+
staleTime: Infinity,
|
|
21
|
+
});
|
|
22
|
+
return data?.semanticSearchEnabled;
|
|
23
|
+
};
|
package/src/index.css
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
the .dark ancestor scope used everywhere in this app. */
|
|
5
5
|
@import "@remit/ui/tokens.css";
|
|
6
6
|
|
|
7
|
+
/* The structural sheet the calendar grid's engine needs, which the kit ships
|
|
8
|
+
beside the component and every app that mounts it has to ask for. Without it
|
|
9
|
+
the week and day grids collapse into a stack and draw no events at all: the
|
|
10
|
+
engine lays its columns out against this sheet, and a column with no
|
|
11
|
+
geometry has nowhere to put an occurrence. */
|
|
12
|
+
@import "@remit/ui/calendar.css";
|
|
13
|
+
|
|
7
14
|
/* Tailwind v4 content scanning: name every source Tailwind must inspect for
|
|
8
15
|
utility class usage. Automatic detection is rooted at the Vite root, which
|
|
9
16
|
for the distributor harness build is a throwaway directory holding only the
|
|
@@ -87,6 +87,7 @@ export const makeThreadMessage = (
|
|
|
87
87
|
|
|
88
88
|
export const makeConfig = (
|
|
89
89
|
accounts: RemitImapAccountResponse[],
|
|
90
|
+
overrides: Partial<RemitImapConfigDescriptionResponse> = {},
|
|
90
91
|
): RemitImapConfigDescriptionResponse => ({
|
|
91
92
|
accountConfig: {
|
|
92
93
|
accountConfigId: "cfg-1",
|
|
@@ -96,4 +97,6 @@ export const makeConfig = (
|
|
|
96
97
|
updatedAt: 0,
|
|
97
98
|
},
|
|
98
99
|
accounts,
|
|
100
|
+
semanticSearchEnabled: true,
|
|
101
|
+
...overrides,
|
|
99
102
|
});
|