@remit/web-client 0.0.237 → 0.0.239

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.
@@ -0,0 +1,449 @@
1
+ /**
2
+ * Sharing a calendar by its secret address, and taking it back (#1067).
3
+ *
4
+ * What is asserted is the request that left and what the panel then shows,
5
+ * because the address is only legible for one render: a control that posted
6
+ * nothing and a control that posted and dropped the answer look identical.
7
+ *
8
+ * Held with the real caches from `lib/query-error-handler.ts` and the overlay
9
+ * they escalate to. Which failures this card owns and which take the whole
10
+ * screen is the decision under test: on the harness's quiet default client
11
+ * every status stays inline, so a card that had quietly lost its 403 would
12
+ * still pass.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { afterEach, describe, it } from "node:test";
17
+ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
18
+ import { createElement, Fragment } from "react";
19
+ import { CalendarFeedPanel } from "@/components/settings/CalendarFeedPanel";
20
+ import { FatalErrorOverlay } from "@/components/ui/FatalErrorOverlay";
21
+ import { __resetFatalError } from "@/lib/fatal-error";
22
+ import {
23
+ handleMutationCacheError,
24
+ handleQueryCacheError,
25
+ } from "@/lib/query-error-handler";
26
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
27
+ import {
28
+ type HttpCall,
29
+ type HttpMock,
30
+ httpError,
31
+ mockFetch,
32
+ } from "../../test-support/http";
33
+
34
+ const WORK = "11111111-1111-4111-8111-111111111111";
35
+ const FEED_PATH = `/calendars/${WORK}/feed`;
36
+ const TOKEN = "9Xq2mB7tK1vHs4dLpZ0rY6wJfN3aC8eQuIoT5gRbVkE";
37
+ const NEXT_TOKEN = "Ab1Cd2Ef3Gh4Ij5Kl6Mn7Op8Qr9St0UvWxYz-_1234A";
38
+ const CREATED = Date.parse("2026-05-04T09:12:00Z");
39
+
40
+ let harness: DomHarness | undefined;
41
+ let http: HttpMock | undefined;
42
+
43
+ afterEach(() => {
44
+ harness?.close();
45
+ harness = undefined;
46
+ http?.restore();
47
+ http = undefined;
48
+ __resetFatalError();
49
+ });
50
+
51
+ type Responder = (call: HttpCall) => unknown;
52
+
53
+ const escalatingClient = (): QueryClient =>
54
+ new QueryClient({
55
+ queryCache: new QueryCache({ onError: handleQueryCacheError }),
56
+ mutationCache: new MutationCache({ onError: handleMutationCacheError }),
57
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
58
+ });
59
+
60
+ const mount = async (responder: Responder) => {
61
+ http = mockFetch(responder);
62
+ harness = createDomHarness({ queryClient: escalatingClient() });
63
+ harness.renderApp(
64
+ createElement(
65
+ Fragment,
66
+ null,
67
+ createElement(FatalErrorOverlay),
68
+ createElement(CalendarFeedPanel, {
69
+ calendarId: WORK,
70
+ calendarName: "Work",
71
+ }),
72
+ ),
73
+ );
74
+ await harness.flush();
75
+ await harness.wait(20);
76
+ await harness.flush();
77
+ };
78
+
79
+ /** The full-screen page took over, instead of this card answering for itself. */
80
+ const escalated = (): boolean =>
81
+ harness?.query('[data-testid="fatal-error-overlay"]') != null;
82
+
83
+ const settle = async () => {
84
+ await harness?.flush();
85
+ await harness?.wait(20);
86
+ await harness?.flush();
87
+ };
88
+
89
+ /**
90
+ * The card's own controls, never the confirmation's. Both spell the action the
91
+ * same way on purpose — the dialog repeats the verb of the button that opened
92
+ * it — so a lookup across the whole panel would answer the confirmation and
93
+ * make "asked before writing" untestable.
94
+ */
95
+ const cardButtons = (): HTMLButtonElement[] =>
96
+ (harness?.queryAll<HTMLButtonElement>("button") ?? []).filter(
97
+ (node) => node.closest('[role="dialog"]') === null,
98
+ );
99
+
100
+ const named = (
101
+ nodes: HTMLButtonElement[],
102
+ label: string,
103
+ ): HTMLButtonElement | undefined =>
104
+ nodes.find((node) => (node.textContent ?? "").trim() === label);
105
+
106
+ const button = (label: string): HTMLButtonElement => {
107
+ const found = named(cardButtons(), label);
108
+ if (!found) throw new Error(`no button labelled "${label}"`);
109
+ return found;
110
+ };
111
+
112
+ const hasButton = (label: string): boolean =>
113
+ named(cardButtons(), label) !== undefined;
114
+
115
+ const confirmButton = (label: string): HTMLButtonElement => {
116
+ const dialog = harness?.query('[role="dialog"]');
117
+ if (!dialog) throw new Error("no confirmation is open");
118
+ const found = named(
119
+ [...dialog.querySelectorAll("button")] as HTMLButtonElement[],
120
+ label,
121
+ );
122
+ if (!found) throw new Error(`the confirmation has no "${label}"`);
123
+ return found;
124
+ };
125
+
126
+ /** A calendar nobody has ever shared: the read is a 404 and owns it. */
127
+ const unshared: Responder = (call) => {
128
+ if (call.method === "GET") return httpError(404, "no feed");
129
+ return {};
130
+ };
131
+
132
+ describe("a calendar that is not shared", () => {
133
+ it("offers to create an address instead of reporting a failure", async () => {
134
+ await mount(unshared);
135
+ assert.match(harness?.text() ?? "", /Create subscription address/);
136
+ assert.doesNotMatch(harness?.text() ?? "", /Couldn't read/);
137
+ });
138
+
139
+ it("mints the address with one PUT to the calendar's feed", async () => {
140
+ let feed: unknown;
141
+ await mount((call) => {
142
+ if (call.method === "PUT") {
143
+ feed = {
144
+ calendarId: WORK,
145
+ feedToken: TOKEN,
146
+ createdAt: CREATED,
147
+ rotatedAt: 0,
148
+ };
149
+ return feed;
150
+ }
151
+ if (feed) return feed;
152
+ return httpError(404, "no feed");
153
+ });
154
+
155
+ harness?.click(button("Create subscription address"));
156
+ await settle();
157
+
158
+ const writes = (http?.to(FEED_PATH) ?? []).filter(
159
+ (call) => call.method === "PUT",
160
+ );
161
+ assert.equal(writes.length, 1, "one write minted the address");
162
+ });
163
+
164
+ /**
165
+ * The one render the plaintext exists in. Nothing stores it, so a panel that
166
+ * dropped it here has handed the reader a calendar they cannot subscribe to
167
+ * without rotating it again.
168
+ */
169
+ it("shows the address once, then never again", async () => {
170
+ let feed: unknown;
171
+ await mount((call) => {
172
+ if (call.method === "PUT") {
173
+ feed = {
174
+ calendarId: WORK,
175
+ feedToken: TOKEN,
176
+ createdAt: CREATED,
177
+ rotatedAt: 0,
178
+ };
179
+ return feed;
180
+ }
181
+ if (feed) return feed;
182
+ return httpError(404, "no feed");
183
+ });
184
+
185
+ harness?.click(button("Create subscription address"));
186
+ await settle();
187
+
188
+ const shown = harness?.text() ?? "";
189
+ assert.match(shown, /shown once and cannot be read back/);
190
+ assert.ok(
191
+ harness
192
+ ?.queryAll<HTMLInputElement>("input")
193
+ .some(
194
+ (input) =>
195
+ input.value === `webcal://localhost/feeds/calendar/${TOKEN}.ics`,
196
+ ),
197
+ `the webcal address was not on screen: ${harness?.html()}`,
198
+ );
199
+
200
+ harness?.click(button("I've saved it"));
201
+ await settle();
202
+
203
+ assert.doesNotMatch(harness?.text() ?? "", /shown once/);
204
+ assert.ok(
205
+ !(harness?.html() ?? "").includes(TOKEN),
206
+ "the dismissed address is gone from the document",
207
+ );
208
+ assert.match(harness?.text() ?? "", /Address created/);
209
+ });
210
+
211
+ /**
212
+ * Off the screen is not gone. React Query keeps a mutation's answer for its
213
+ * whole gcTime, and that answer is the plaintext token — the one thing this
214
+ * feature promises exists nowhere it can be read back from.
215
+ */
216
+ it("drops the minted token from the mutation cache when it is dismissed", async () => {
217
+ let feed: unknown;
218
+ await mount((call) => {
219
+ if (call.method === "PUT") {
220
+ feed = {
221
+ calendarId: WORK,
222
+ feedToken: TOKEN,
223
+ createdAt: CREATED,
224
+ rotatedAt: 0,
225
+ };
226
+ return feed;
227
+ }
228
+ if (feed) return feed;
229
+ return httpError(404, "no feed");
230
+ });
231
+
232
+ harness?.click(button("Create subscription address"));
233
+ await settle();
234
+
235
+ const cached = () =>
236
+ (harness?.queryClient.getMutationCache().getAll() ?? []).filter(
237
+ (mutation) => mutation.state.data !== undefined,
238
+ );
239
+ assert.equal(cached().length, 1, "the answer is held while it is shown");
240
+
241
+ harness?.click(button("I've saved it"));
242
+ await settle();
243
+
244
+ assert.deepEqual(cached(), [], "no cached answer still holds the token");
245
+ assert.ok(
246
+ !JSON.stringify(
247
+ (harness?.queryClient.getMutationCache().getAll() ?? []).map(
248
+ (mutation) => mutation.state,
249
+ ),
250
+ ).includes(TOKEN),
251
+ "the token survived in the mutation cache",
252
+ );
253
+ });
254
+
255
+ it("states a refused write where the button is", async () => {
256
+ await mount((call) => {
257
+ if (call.method === "PUT") return httpError(404, "Calendar not found");
258
+ return httpError(404, "no feed");
259
+ });
260
+
261
+ harness?.click(button("Create subscription address"));
262
+ await settle();
263
+
264
+ assert.match(harness?.text() ?? "", /was not changed/);
265
+ assert.match(harness?.text() ?? "", /Calendar not found/);
266
+ assert.ok(!escalated(), "the card owns a refused write, inline");
267
+ });
268
+ });
269
+
270
+ describe("a calendar that is shared", () => {
271
+ const shared =
272
+ (rotatedAt: number): Responder =>
273
+ (call) => {
274
+ if (call.method === "GET")
275
+ return { calendarId: WORK, createdAt: CREATED, rotatedAt };
276
+ return {};
277
+ };
278
+
279
+ it("shows no address for the feed it merely knows exists", async () => {
280
+ await mount(shared(0));
281
+ assert.match(harness?.text() ?? "", /Address created/);
282
+ assert.ok(!(harness?.html() ?? "").includes("webcal://"));
283
+ });
284
+
285
+ it("asks before replacing the address, and says the old one stops", async () => {
286
+ await mount(shared(0));
287
+
288
+ harness?.click(button("Replace address"));
289
+ await settle();
290
+
291
+ const asked = harness?.text() ?? "";
292
+ assert.match(asked, /Replace the address for Work\?/);
293
+ assert.match(asked, /stops updating/);
294
+ assert.equal(
295
+ (http?.to(FEED_PATH) ?? []).filter((call) => call.method === "PUT")
296
+ .length,
297
+ 0,
298
+ "nothing was written before the confirmation was answered",
299
+ );
300
+ });
301
+
302
+ it("replaces the address and hands back the new one, once", async () => {
303
+ let rotated = false;
304
+ await mount((call) => {
305
+ if (call.method === "PUT") {
306
+ rotated = true;
307
+ return {
308
+ calendarId: WORK,
309
+ feedToken: NEXT_TOKEN,
310
+ createdAt: CREATED,
311
+ rotatedAt: CREATED + 1000,
312
+ };
313
+ }
314
+ return {
315
+ calendarId: WORK,
316
+ createdAt: CREATED,
317
+ rotatedAt: rotated ? CREATED + 1000 : 0,
318
+ };
319
+ });
320
+
321
+ harness?.click(button("Replace address"));
322
+ await settle();
323
+ harness?.click(confirmButton("Replace address"));
324
+ await settle();
325
+
326
+ assert.equal(
327
+ (http?.to(FEED_PATH) ?? []).filter((call) => call.method === "PUT")
328
+ .length,
329
+ 1,
330
+ );
331
+ assert.ok(
332
+ harness
333
+ ?.queryAll<HTMLInputElement>("input")
334
+ .some((input) => input.value.includes(NEXT_TOKEN)),
335
+ "the replacement address was shown",
336
+ );
337
+ assert.match(harness?.text() ?? "", /last replaced/);
338
+ });
339
+
340
+ it("asks before revoking, then leaves the calendar unshared", async () => {
341
+ let revoked = false;
342
+ await mount((call) => {
343
+ if (call.method === "DELETE") {
344
+ revoked = true;
345
+ return {};
346
+ }
347
+ if (revoked) return httpError(404, "no feed");
348
+ return { calendarId: WORK, createdAt: CREATED, rotatedAt: 0 };
349
+ });
350
+
351
+ harness?.click(button("Stop sharing"));
352
+ await settle();
353
+ assert.match(harness?.text() ?? "", /Stop sharing Work\?/);
354
+
355
+ harness?.click(confirmButton("Stop sharing"));
356
+ await settle();
357
+
358
+ assert.equal(
359
+ (http?.to(FEED_PATH) ?? []).filter((call) => call.method === "DELETE")
360
+ .length,
361
+ 1,
362
+ );
363
+ assert.ok(hasButton("Create subscription address"));
364
+ assert.doesNotMatch(harness?.text() ?? "", /Address created/);
365
+ });
366
+
367
+ /**
368
+ * A refusal is about the write that was refused, not about the card. Left
369
+ * standing, it reads as if the replacement that just landed had failed too —
370
+ * over an address that is on screen and working.
371
+ */
372
+ it("clears a refused revoke once the next write succeeds", async () => {
373
+ let rotated = false;
374
+ await mount((call) => {
375
+ if (call.method === "DELETE") return httpError(409, "Feed is locked");
376
+ if (call.method === "PUT") {
377
+ rotated = true;
378
+ return {
379
+ calendarId: WORK,
380
+ feedToken: NEXT_TOKEN,
381
+ createdAt: CREATED,
382
+ rotatedAt: CREATED + 1000,
383
+ };
384
+ }
385
+ return {
386
+ calendarId: WORK,
387
+ createdAt: CREATED,
388
+ rotatedAt: rotated ? CREATED + 1000 : 0,
389
+ };
390
+ });
391
+
392
+ harness?.click(button("Stop sharing"));
393
+ await settle();
394
+ harness?.click(confirmButton("Stop sharing"));
395
+ await settle();
396
+ assert.match(harness?.text() ?? "", /Feed is locked/);
397
+
398
+ harness?.click(button("Replace address"));
399
+ await settle();
400
+ harness?.click(confirmButton("Replace address"));
401
+ await settle();
402
+
403
+ assert.doesNotMatch(
404
+ harness?.text() ?? "",
405
+ /was not changed/,
406
+ "the refused revoke outlived the replacement that succeeded",
407
+ );
408
+ assert.doesNotMatch(harness?.text() ?? "", /Feed is locked/);
409
+ assert.ok(
410
+ harness
411
+ ?.queryAll<HTMLInputElement>("input")
412
+ .some((input) => input.value.includes(NEXT_TOKEN)),
413
+ "the replacement address was shown",
414
+ );
415
+ });
416
+ });
417
+
418
+ describe("a read the server refuses", () => {
419
+ /**
420
+ * A 403 is not "not shared". Drawing the create button for it would tell the
421
+ * reader their calendar is private while it may be subscribed to right now.
422
+ */
423
+ it("says the state is unknown rather than offering to create one", async () => {
424
+ await mount((call) => {
425
+ if (call.method === "GET") return httpError(403, "not yours");
426
+ return {};
427
+ });
428
+
429
+ assert.ok(
430
+ !escalated(),
431
+ "a 403 the card draws inline must not take the whole screen",
432
+ );
433
+ assert.match(harness?.text() ?? "", /Couldn't read whether Work is shared/);
434
+ assert.ok(!hasButton("Create subscription address"));
435
+ });
436
+
437
+ /**
438
+ * The one status this card may not keep to itself. No banner here signs
439
+ * anyone back in, so a 401 belongs on the page that does.
440
+ */
441
+ it("escalates a lapsed session instead of drawing it in the card", async () => {
442
+ await mount((call) => {
443
+ if (call.method === "GET") return httpError(401, "signed out");
444
+ return {};
445
+ });
446
+
447
+ assert.ok(escalated(), "a 401 on the read stayed inside the card");
448
+ });
449
+ });
@@ -0,0 +1,163 @@
1
+ /**
2
+ * A calendar's secret subscription address, as the settings surface holds it
3
+ * (issue #1067).
4
+ *
5
+ * The plaintext token exists in exactly one place: the answer to the write that
6
+ * minted it. It is kept here until the reader dismisses it and is never read
7
+ * back — the server stores a hash, so a lost address is rotated rather than
8
+ * recovered.
9
+ *
10
+ * The read owns its 404 and its 403 and nothing else. "This calendar has no
11
+ * feed" and "this calendar is not yours" are both answers the card states where
12
+ * it stands — the 404 as the offer to create one, the 403 as "couldn't read
13
+ * whether it is shared", never as "not shared yet". A 401 is the session gone,
14
+ * and no banner on this card signs anyone back in, so it escalates.
15
+ */
16
+ import {
17
+ calendarDetailOperationsGetCalendarFeedOptions,
18
+ calendarDetailOperationsGetCalendarFeedQueryKey,
19
+ calendarDetailOperationsPutCalendarFeedMutation,
20
+ calendarDetailOperationsRevokeCalendarFeedMutation,
21
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
22
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
23
+ import { useCallback, useRef, useState } from "react";
24
+ import { calendarFeedUrl } from "@/lib/calendar-feed-url";
25
+ import {
26
+ isNotFound,
27
+ softErrorMeta,
28
+ softErrorStatuses,
29
+ } from "@/lib/error-classifier";
30
+
31
+ export type CalendarFeedState =
32
+ | { status: "loading" }
33
+ /** The server answered, and this calendar is not shared. */
34
+ | { status: "absent" }
35
+ | { status: "active"; createdAt: number; rotatedAt: number }
36
+ /** The server refused to say, which is not the same as "not shared". */
37
+ | { status: "unreadable"; error: unknown };
38
+
39
+ export interface CalendarFeedControls {
40
+ state: CalendarFeedState;
41
+ /** The address just minted, shown once. Empty at every other moment. */
42
+ mintedUrl: string;
43
+ isBusy: boolean;
44
+ /** A create, rotate or revoke the server turned down. */
45
+ actionError: unknown;
46
+ mint: () => void;
47
+ revoke: () => void;
48
+ dismissMinted: () => void;
49
+ retry: () => void;
50
+ }
51
+
52
+ export function useCalendarFeed(calendarId: string): CalendarFeedControls {
53
+ const queryClient = useQueryClient();
54
+ const [mintedUrl, setMintedUrl] = useState("");
55
+
56
+ const queryKey = calendarDetailOperationsGetCalendarFeedQueryKey({
57
+ path: { calendarId },
58
+ });
59
+
60
+ const query = useQuery({
61
+ ...calendarDetailOperationsGetCalendarFeedOptions({
62
+ path: { calendarId },
63
+ }),
64
+ meta: softErrorStatuses(404, 403),
65
+ retry: false,
66
+ });
67
+
68
+ const invalidate = () => {
69
+ queryClient.invalidateQueries({ queryKey });
70
+ };
71
+
72
+ // Each write clears the other's refusal as it starts, so the card never
73
+ // states a failure the reader has already written past. Only one of the two
74
+ // can name the other directly; the revoke is declared first and reaches
75
+ // forward through this ref.
76
+ const resetMint = useRef<() => void>(() => {});
77
+
78
+ const revokeMutation = useMutation({
79
+ ...calendarDetailOperationsRevokeCalendarFeedMutation(),
80
+ meta: softErrorMeta,
81
+ onMutate: () => {
82
+ resetMint.current();
83
+ },
84
+ onSuccess: () => {
85
+ setMintedUrl("");
86
+ invalidate();
87
+ },
88
+ });
89
+
90
+ const mintMutation = useMutation({
91
+ ...calendarDetailOperationsPutCalendarFeedMutation(),
92
+ meta: softErrorMeta,
93
+ // The answer to this write is the plaintext token. Nothing may hold it
94
+ // once the surface showing it lets go, so it is collected the moment the
95
+ // observer detaches — on dismissal, and on leaving the page — instead of
96
+ // sitting in the mutation cache for the default five minutes.
97
+ gcTime: 0,
98
+ onMutate: () => {
99
+ revokeMutation.reset();
100
+ },
101
+ onSuccess: (data) => {
102
+ setMintedUrl(calendarFeedUrl(window.location.host, data.feedToken));
103
+ invalidate();
104
+ },
105
+ });
106
+
107
+ resetMint.current = mintMutation.reset;
108
+
109
+ const { mutate: mintFeed, reset: resetMintMutation } = mintMutation;
110
+ const { mutate: revokeFeed } = revokeMutation;
111
+
112
+ const mint = useCallback(() => {
113
+ mintFeed({ path: { calendarId } });
114
+ }, [calendarId, mintFeed]);
115
+
116
+ const revoke = useCallback(() => {
117
+ revokeFeed({ path: { calendarId } });
118
+ }, [calendarId, revokeFeed]);
119
+
120
+ // Resetting the mutation drops the answer that carried the plaintext token,
121
+ // which otherwise sits in the mutation cache for its whole gcTime after the
122
+ // reader has said they saved it.
123
+ const dismissMinted = useCallback(() => {
124
+ setMintedUrl("");
125
+ resetMintMutation();
126
+ }, [resetMintMutation]);
127
+
128
+ const { refetch } = query;
129
+ const retry = useCallback(() => {
130
+ refetch();
131
+ }, [refetch]);
132
+
133
+ return {
134
+ state: readState(query),
135
+ mintedUrl,
136
+ isBusy: mintMutation.isPending || revokeMutation.isPending,
137
+ actionError: mintMutation.error ?? revokeMutation.error,
138
+ mint,
139
+ revoke,
140
+ dismissMinted,
141
+ retry,
142
+ };
143
+ }
144
+
145
+ interface FeedQuery {
146
+ data?: { createdAt: number; rotatedAt: number };
147
+ error: unknown;
148
+ isPending: boolean;
149
+ }
150
+
151
+ function readState(query: FeedQuery): CalendarFeedState {
152
+ if (query.error) {
153
+ if (isNotFound(query.error)) return { status: "absent" };
154
+ return { status: "unreadable", error: query.error };
155
+ }
156
+ if (query.isPending) return { status: "loading" };
157
+ if (!query.data) return { status: "absent" };
158
+ return {
159
+ status: "active",
160
+ createdAt: query.data.createdAt,
161
+ rotatedAt: query.data.rotatedAt,
162
+ };
163
+ }
@@ -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
- enabled: Boolean(thread && semanticQuery.length > 3),
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
+ };