@agent-native/core 0.171.0 → 0.171.3

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.
Files changed (40) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +4 -5
  3. package/corpus/templates/analytics/server/lib/workspace-feature-flags.ts +84 -20
  4. package/corpus/templates/calendar/actions/create-event.ts +11 -3
  5. package/corpus/templates/calendar/app/components/calendar/AddCalendarDialog.tsx +56 -51
  6. package/corpus/templates/calendar/app/components/calendar/CreateEventDialog.tsx +3 -0
  7. package/corpus/templates/calendar/app/components/calendar/DayView.tsx +1 -1
  8. package/corpus/templates/calendar/app/components/calendar/PeopleSearchDialog.tsx +54 -51
  9. package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +1 -1
  10. package/corpus/templates/calendar/app/components/layout/AppLayout.tsx +4 -1
  11. package/corpus/templates/calendar/app/hooks/use-people.ts +23 -13
  12. package/corpus/templates/calendar/app/lib/event-layout.ts +21 -6
  13. package/corpus/templates/calendar/app/pages/CalendarView.tsx +3 -0
  14. package/corpus/templates/calendar/changelog/2026-08-24-overlapping-events-use-space-better.md +5 -0
  15. package/corpus/templates/calendar/server/lib/google-api.ts +9 -0
  16. package/corpus/templates/calendar/server/lib/google-calendar.ts +59 -2
  17. package/corpus/templates/calendar/server/lib/people-search.ts +43 -28
  18. package/corpus/templates/calendar/shared/api.ts +2 -0
  19. package/corpus/templates/clips/app/components/player/playback-comment-overlay.tsx +3 -3
  20. package/corpus/templates/mail/app/root.tsx +29 -0
  21. package/dist/action.d.ts +4 -1
  22. package/dist/action.js +2 -0
  23. package/dist/client/DefaultSpinner.d.ts +3 -1
  24. package/dist/client/DefaultSpinner.js +2 -2
  25. package/dist/client/chat-first/apps-rail.d.ts +1 -1
  26. package/dist/client/chat-first/apps-rail.js +11 -8
  27. package/dist/client/chat-first/copy.js +1 -0
  28. package/dist/client/chat-first/types.d.ts +1 -0
  29. package/dist/client/use-action.js +8 -0
  30. package/dist/collab/awareness.d.ts +2 -2
  31. package/dist/collab/routes.d.ts +1 -1
  32. package/dist/notifications/routes.d.ts +1 -1
  33. package/dist/observability/routes.d.ts +3 -3
  34. package/dist/progress/routes.d.ts +1 -1
  35. package/dist/resources/handlers.d.ts +3 -3
  36. package/dist/secrets/routes.d.ts +6 -6
  37. package/dist/server/action-routes.js +6 -2
  38. package/dist/server/realtime-token.d.ts +1 -1
  39. package/dist/server/transcribe-voice.d.ts +1 -1
  40. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8585
34
+ - template files: 8586
@@ -2450,17 +2450,15 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) {
2450
2450
  : "text-muted-foreground hover:bg-sidebar-accent/50",
2451
2451
  )}
2452
2452
  >
2453
- <button
2454
- type="button"
2455
- onClick={toggleDashOpen}
2453
+ <Link
2454
+ to="/dashboards"
2456
2455
  className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2 text-start"
2457
- aria-expanded={dashOpen}
2458
2456
  >
2459
2457
  <IconChartBar className="h-4 w-4 shrink-0" />
2460
2458
  <span className="min-w-0 flex-1 truncate">
2461
2459
  {t("navigation.dashboards")}
2462
2460
  </span>
2463
- </button>
2461
+ </Link>
2464
2462
  <SidebarSectionSettingsPopover
2465
2463
  label={t("navigation.dashboards")}
2466
2464
  sortMode={dashboardSortMode}
@@ -2477,6 +2475,7 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) {
2477
2475
  ? t("sidebar.collapseDashboards")
2478
2476
  : t("sidebar.expandDashboards")
2479
2477
  }
2478
+ aria-expanded={dashOpen}
2480
2479
  >
2481
2480
  <IconChevronDown
2482
2481
  className={cn(
@@ -1,6 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
 
3
3
  import { signA2AToken } from "@agent-native/core/a2a";
4
+ import {
5
+ ActionContractError,
6
+ AgentActionStopError,
7
+ } from "@agent-native/core/action";
4
8
  import { isFeatureFlagEnabled } from "@agent-native/core/feature-flags";
5
9
  import { fetchOrgApps, type OrgApp } from "@agent-native/core/mcp";
6
10
  import { getOrgDomain } from "@agent-native/core/org";
@@ -9,6 +13,7 @@ import { VERIFIED_FLEET_FLAG_MUTATIONS } from "../../shared/feature-flags.js";
9
13
  import type { AnalyticsAdminContext } from "./db-admin-connections.js";
10
14
 
11
15
  const TARGET_TIMEOUT_MS = 3_000;
16
+ const VERIFICATION_ATTEMPTS = 2;
12
17
  const CONCURRENCY = 4;
13
18
 
14
19
  export type FleetFlagState =
@@ -197,6 +202,8 @@ export type WorkspaceFeatureFlagFailurePhase =
197
202
  | "unsupported-target"
198
203
  | "target-action"
199
204
  | "persistence"
205
+ | "verification-timeout"
206
+ | "verification-network"
200
207
  | "verification";
201
208
 
202
209
  const FAILURE_MESSAGES: Record<WorkspaceFeatureFlagFailurePhase, string> = {
@@ -211,16 +218,42 @@ const FAILURE_MESSAGES: Record<WorkspaceFeatureFlagFailurePhase, string> = {
211
218
  "target-action": "The target app could not complete the feature flag action.",
212
219
  persistence:
213
220
  "The target app did not persist the requested feature flag rules.",
221
+ "verification-timeout":
222
+ "The feature flag change persisted, but the target app timed out during verification.",
223
+ "verification-network":
224
+ "The feature flag change persisted, but Analytics could not reach the target app during verification.",
214
225
  verification: "Analytics could not verify the persisted feature flag change.",
215
226
  };
216
227
 
217
- export class WorkspaceFeatureFlagFailure extends Error {
228
+ export class WorkspaceFeatureFlagFailure extends AgentActionStopError {
218
229
  constructor(readonly phase: WorkspaceFeatureFlagFailurePhase) {
219
- super(`[${phase}] ${FAILURE_MESSAGES[phase]}`);
230
+ const message = `[${phase}] ${FAILURE_MESSAGES[phase]}`;
231
+ const errorCode = `workspace_feature_flag_${phase.replace("-", "_")}`;
232
+ super(message, {
233
+ errorCode,
234
+ details: { phase },
235
+ toolResult: JSON.stringify({ error: errorCode, phase, message }),
236
+ });
220
237
  this.name = "WorkspaceFeatureFlagFailure";
221
238
  }
222
239
  }
223
240
 
241
+ class WorkspaceFeatureFlagSetupFailure extends ActionContractError {
242
+ readonly phase: "directory" | "token-generation";
243
+
244
+ constructor(phase: "directory" | "token-generation") {
245
+ const message = `[${phase}] ${FAILURE_MESSAGES[phase]}`;
246
+ const errorCode = `workspace_feature_flag_${phase.replace("-", "_")}`;
247
+ super(message, {
248
+ errorCode,
249
+ details: { phase },
250
+ statusCode: 503,
251
+ });
252
+ this.name = "WorkspaceFeatureFlagSetupFailure";
253
+ this.phase = phase;
254
+ }
255
+ }
256
+
224
257
  type TargetFailureReason = "token-generation" | "timeout" | "network";
225
258
 
226
259
  class TargetCallFailure extends Error {
@@ -242,6 +275,16 @@ export function classifyWorkspaceFeatureFlagTargetFailure(
242
275
  return "network";
243
276
  }
244
277
 
278
+ function isSyntaxError(error: unknown): boolean {
279
+ return (
280
+ error instanceof SyntaxError ||
281
+ (!!error &&
282
+ typeof error === "object" &&
283
+ "name" in error &&
284
+ error.name === "SyntaxError")
285
+ );
286
+ }
287
+
245
288
  function targetFailure(error: unknown): WorkspaceFeatureFlagFailure {
246
289
  const reason = classifyWorkspaceFeatureFlagTargetFailure(error);
247
290
  return new WorkspaceFeatureFlagFailure(reason);
@@ -259,10 +302,10 @@ async function resolveTargetApp(
259
302
  serviceOrgId: admin.orgId,
260
303
  });
261
304
  } catch {
262
- throw new WorkspaceFeatureFlagFailure("directory");
305
+ throw new WorkspaceFeatureFlagSetupFailure("directory");
263
306
  }
264
307
  const app = apps.find((candidate) => candidate.id === appId);
265
- if (!app) throw new WorkspaceFeatureFlagFailure("directory");
308
+ if (!app) throw new WorkspaceFeatureFlagSetupFailure("directory");
266
309
  return app;
267
310
  }
268
311
 
@@ -302,12 +345,41 @@ async function callTarget(
302
345
  let parsed: unknown = null;
303
346
  try {
304
347
  parsed = await response.json();
305
- } catch {
306
- // A legacy/non-action endpoint is classified below without reflecting body.
348
+ } catch (error) {
349
+ const successfulResponse = response.status >= 200 && response.status < 300;
350
+ if (successfulResponse && !isSyntaxError(error))
351
+ throw new TargetCallFailure(
352
+ classifyWorkspaceFeatureFlagTargetFailure(error),
353
+ );
354
+ // Invalid legacy payloads and non-success statuses are classified below.
307
355
  }
308
356
  return { status: response.status, body: parsed };
309
357
  }
310
358
 
359
+ async function readBackTarget(
360
+ app: OrgApp,
361
+ admin: AnalyticsAdminContext,
362
+ orgDomain: string,
363
+ ): Promise<{ status: number; body: unknown }> {
364
+ for (let attempt = 1; attempt <= VERIFICATION_ATTEMPTS; attempt += 1) {
365
+ try {
366
+ return await callTarget(app, admin, "list-feature-flags", {}, orgDomain);
367
+ } catch (error) {
368
+ const reason = classifyWorkspaceFeatureFlagTargetFailure(error);
369
+ const retryable = reason === "timeout" || reason === "network";
370
+ if (retryable && attempt < VERIFICATION_ATTEMPTS) continue;
371
+ throw new WorkspaceFeatureFlagFailure(
372
+ reason === "timeout"
373
+ ? "verification-timeout"
374
+ : reason === "network"
375
+ ? "verification-network"
376
+ : reason,
377
+ );
378
+ }
379
+ }
380
+ throw new WorkspaceFeatureFlagFailure("verification");
381
+ }
382
+
311
383
  export function classifyWorkspaceFeatureFlagList(
312
384
  app: OrgApp,
313
385
  result: { status: number; body: unknown },
@@ -404,9 +476,10 @@ export async function setWorkspaceFeatureFlag(
404
476
  try {
405
477
  orgDomain = (await getOrgDomain(admin.orgId))?.trim().toLowerCase();
406
478
  } catch {
407
- throw new WorkspaceFeatureFlagFailure("token-generation");
479
+ throw new WorkspaceFeatureFlagSetupFailure("token-generation");
408
480
  }
409
- if (!orgDomain) throw new WorkspaceFeatureFlagFailure("token-generation");
481
+ if (!orgDomain)
482
+ throw new WorkspaceFeatureFlagSetupFailure("token-generation");
410
483
  let result: Awaited<ReturnType<typeof callTarget>>;
411
484
  try {
412
485
  result = await callTarget(
@@ -417,6 +490,8 @@ export async function setWorkspaceFeatureFlag(
417
490
  orgDomain,
418
491
  );
419
492
  } catch (error) {
493
+ if (classifyWorkspaceFeatureFlagTargetFailure(error) === "token-generation")
494
+ throw new WorkspaceFeatureFlagSetupFailure("token-generation");
420
495
  throw targetFailure(error);
421
496
  }
422
497
  if (result.status === 401 || result.status === 403)
@@ -465,18 +540,7 @@ export async function setWorkspaceFeatureFlag(
465
540
  return mutation;
466
541
  }
467
542
 
468
- let readBack: Awaited<ReturnType<typeof callTarget>>;
469
- try {
470
- readBack = await callTarget(
471
- app,
472
- admin,
473
- "list-feature-flags",
474
- {},
475
- orgDomain,
476
- );
477
- } catch (error) {
478
- throw targetFailure(error);
479
- }
543
+ const readBack = await readBackTarget(app, admin, orgDomain);
480
544
  if (readBack.status === 401 || readBack.status === 403)
481
545
  throw new WorkspaceFeatureFlagFailure("authorization");
482
546
  if (readBack.status === 404 || readBack.status === 405)
@@ -216,10 +216,16 @@ export default defineAction({
216
216
  };
217
217
 
218
218
  let zoomMeetingLink: string | undefined;
219
+ let videoConferenceError: CalendarEvent["videoConferenceError"];
219
220
  if (args.addZoom) {
220
- const zoom = await prepareZoomMeetingPatch(email, calEvent);
221
- zoomMeetingLink = zoom.meetingLink;
222
- Object.assign(calEvent, zoom.patch);
221
+ try {
222
+ const zoom = await prepareZoomMeetingPatch(email, calEvent);
223
+ zoomMeetingLink = zoom.meetingLink;
224
+ Object.assign(calEvent, zoom.patch);
225
+ } catch (error) {
226
+ videoConferenceError = "zoom";
227
+ console.error("[create-event] Zoom meeting provisioning failed", error);
228
+ }
223
229
  }
224
230
 
225
231
  const result = await googleCalendar.createEvent(calEvent, {
@@ -238,6 +244,8 @@ export default defineAction({
238
244
  if (result.meetLink) calEvent.hangoutLink = result.meetLink;
239
245
  if (result.conferenceData) calEvent.conferenceData = result.conferenceData;
240
246
  if (zoomMeetingLink) calEvent.meetingLink = zoomMeetingLink;
247
+ if (videoConferenceError)
248
+ calEvent.videoConferenceError = videoConferenceError;
241
249
 
242
250
  try {
243
251
  emit(
@@ -1,4 +1,3 @@
1
- import { callAction } from "@agent-native/core/client/hooks";
2
1
  import { useT } from "@agent-native/core/client/i18n";
3
2
  import {
4
3
  IconSearch,
@@ -8,7 +7,7 @@ import {
8
7
  IconLink,
9
8
  IconCalendarPlus,
10
9
  } from "@tabler/icons-react";
11
- import { useState, useEffect, useRef, useCallback } from "react";
10
+ import { useState, useEffect, useMemo, useRef } from "react";
12
11
  import { toast } from "sonner";
13
12
 
14
13
  import { Button } from "@/components/ui/button";
@@ -31,17 +30,12 @@ import {
31
30
  useAddOverlayPerson,
32
31
  useRemoveOverlayPerson,
33
32
  } from "@/hooks/use-overlay-people";
34
-
35
- interface SearchResult {
36
- name: string;
37
- email: string;
38
- photoUrl?: string;
39
- }
40
-
41
- interface SearchResponse {
42
- results: SearchResult[];
43
- scopeRequired?: boolean;
44
- }
33
+ import {
34
+ filterPeopleResults,
35
+ mergePeopleResults,
36
+ usePeopleContacts,
37
+ usePeopleSearch,
38
+ } from "@/hooks/use-people";
45
39
 
46
40
  const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
47
41
  const URL_REGEX = /^(https?|webcal):\/\/.+/i;
@@ -67,7 +61,7 @@ export function AddCalendarDialog({
67
61
 
68
62
  return (
69
63
  <Dialog open={open} onOpenChange={onOpenChange}>
70
- <DialogContent className="sm:max-w-[440px] gap-0 p-0 top-[8%] translate-y-0">
64
+ <DialogContent className="sm:max-w-[440px] max-h-[calc(85vh-1rem)] gap-0 overflow-y-auto p-0 top-[15vh] !translate-y-0">
71
65
  <DialogHeader className="px-4 pt-4 pb-0">
72
66
  <DialogTitle className="text-base">
73
67
  {t("eventForm.addCalendar")}
@@ -89,7 +83,7 @@ export function AddCalendarDialog({
89
83
  </TabsList>
90
84
 
91
85
  <TabsContent value="people" className="mt-0">
92
- <PeopleTab onClose={() => onOpenChange(false)} />
86
+ <PeopleTab open={open} />
93
87
  </TabsContent>
94
88
 
95
89
  <TabsContent value="url" className="mt-0 px-4 pb-4 pt-3">
@@ -103,15 +97,12 @@ export function AddCalendarDialog({
103
97
 
104
98
  // ─── People tab ──────────────────────────────────────────────────────────────
105
99
 
106
- function PeopleTab({ onClose: _ }: { onClose: () => void }) {
100
+ function PeopleTab({ open }: { open: boolean }) {
107
101
  const t = useT();
108
102
  const [query, setQuery] = useState("");
109
- const [results, setResults] = useState<SearchResult[]>([]);
110
- const [scopeRequired, setScopeRequired] = useState(false);
111
- const [searching, setSearching] = useState(false);
103
+ const [searchQuery, setSearchQuery] = useState("");
112
104
  const [activeIndex, setActiveIndex] = useState(-1);
113
105
  const inputRef = useRef<HTMLInputElement>(null);
114
- const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
115
106
  const listRef = useRef<HTMLDivElement>(null);
116
107
  const shouldScrollActiveResultRef = useRef(false);
117
108
 
@@ -119,46 +110,57 @@ function PeopleTab({ onClose: _ }: { onClose: () => void }) {
119
110
  const overlayPeople = Array.isArray(rawOverlayPeople) ? rawOverlayPeople : [];
120
111
  const addPerson = useAddOverlayPerson();
121
112
  const removePerson = useRemoveOverlayPerson();
113
+ const contacts = usePeopleContacts("directory", open);
114
+ const directorySearch = usePeopleSearch(searchQuery, open, "directory");
122
115
 
123
- const overlayEmails = new Set(overlayPeople.map((p) => p.email));
124
- const selectableResults = results.filter((r) => !overlayEmails.has(r.email));
125
-
126
- const search = useCallback(async (q: string) => {
127
- setSearching(true);
128
- try {
129
- const data = await callAction<SearchResponse>(
130
- "search-people",
131
- q ? { q, scope: "directory" } : { scope: "directory" },
132
- { method: "GET" },
133
- );
134
- setResults(data.results ?? []);
135
- setScopeRequired(data.scopeRequired ?? false);
136
- } catch {
137
- // ignore
138
- } finally {
139
- setSearching(false);
140
- }
141
- }, []);
116
+ const overlayEmails = useMemo(
117
+ () => new Set(overlayPeople.map((p) => p.email.toLowerCase())),
118
+ [overlayPeople],
119
+ );
120
+
121
+ const results = useMemo(
122
+ () =>
123
+ filterPeopleResults(
124
+ mergePeopleResults(
125
+ contacts.data?.results,
126
+ directorySearch.data?.results,
127
+ ),
128
+ query,
129
+ new Set(),
130
+ ),
131
+ [contacts.data?.results, directorySearch.data?.results, query],
132
+ );
133
+ const selectableResults = results.filter(
134
+ (r) => !overlayEmails.has(r.email.toLowerCase()),
135
+ );
136
+
137
+ const searching =
138
+ contacts.isLoading ||
139
+ contacts.isFetching ||
140
+ directorySearch.isLoading ||
141
+ directorySearch.isFetching;
142
+ const scopeRequired = Boolean(
143
+ contacts.data?.scopeRequired || directorySearch.data?.scopeRequired,
144
+ );
142
145
 
143
146
  useEffect(() => {
144
- if (debounceRef.current) clearTimeout(debounceRef.current);
145
- debounceRef.current = setTimeout(() => search(query), query ? 300 : 0);
146
- return () => {
147
- if (debounceRef.current) clearTimeout(debounceRef.current);
148
- };
149
- }, [query, search]);
147
+ const timeout = window.setTimeout(
148
+ () => setSearchQuery(query),
149
+ query.trim() ? 300 : 0,
150
+ );
151
+ return () => window.clearTimeout(timeout);
152
+ }, [query]);
150
153
 
151
154
  useEffect(() => {
152
155
  setActiveIndex(results.length > 0 ? 0 : -1);
153
156
  }, [results]);
154
157
 
155
158
  useEffect(() => {
159
+ if (!open) return;
156
160
  setQuery("");
157
- setResults([]);
158
- setScopeRequired(false);
161
+ setSearchQuery("");
159
162
  setActiveIndex(-1);
160
- search("");
161
- }, [search]);
163
+ }, [open]);
162
164
 
163
165
  useEffect(() => {
164
166
  if (
@@ -198,7 +200,10 @@ function PeopleTab({ onClose: _ }: { onClose: () => void }) {
198
200
  return;
199
201
  }
200
202
  const trimmed = query.trim();
201
- if (EMAIL_REGEX.test(trimmed) && !overlayEmails.has(trimmed)) {
203
+ if (
204
+ EMAIL_REGEX.test(trimmed) &&
205
+ !overlayEmails.has(trimmed.toLowerCase())
206
+ ) {
202
207
  handleAdd(trimmed);
203
208
  setQuery("");
204
209
  }
@@ -229,7 +234,7 @@ function PeopleTab({ onClose: _ }: { onClose: () => void }) {
229
234
  className="max-h-48 overflow-y-auto border-t border-border"
230
235
  >
231
236
  {results.map((person) => {
232
- const alreadyAdded = overlayEmails.has(person.email);
237
+ const alreadyAdded = overlayEmails.has(person.email.toLowerCase());
233
238
  const selectableIdx = selectableResults.indexOf(person);
234
239
  const isActive = !alreadyAdded && selectableIdx === activeIndex;
235
240
  return (
@@ -856,6 +856,9 @@ export function CreateEventPopover({
856
856
  deletePersistedDraft(activeDraftId);
857
857
  onDraftCreated?.(activeDraftId);
858
858
  }
859
+ if (result?.videoConferenceError === "zoom") {
860
+ toast.error(t("eventForm.zoomAddFailed"));
861
+ }
859
862
  const eventId = result?.id;
860
863
  const undo = eventId
861
864
  ? () => {
@@ -297,7 +297,7 @@ const DayEventCard = memo(function DayEventCard({
297
297
  style={{
298
298
  ...posStyle,
299
299
  left: `calc(${li.left}% + ${li.indent}px)`,
300
- width: `calc(${li.width}% - ${li.indent * 2 + 2}px)`,
300
+ width: `calc(${li.width}% - ${li.indent + 2}px)`,
301
301
  zIndex:
302
302
  isBeingDragged && isDragging
303
303
  ? 100
@@ -1,4 +1,3 @@
1
- import { callAction } from "@agent-native/core/client/hooks";
2
1
  import { useT } from "@agent-native/core/client/i18n";
3
2
  import {
4
3
  IconSearch,
@@ -6,7 +5,7 @@ import {
6
5
  IconUserPlus,
7
6
  IconLoader2,
8
7
  } from "@tabler/icons-react";
9
- import { useState, useEffect, useRef, useCallback } from "react";
8
+ import { useState, useEffect, useMemo, useRef } from "react";
10
9
 
11
10
  import {
12
11
  Dialog,
@@ -19,17 +18,12 @@ import {
19
18
  useAddOverlayPerson,
20
19
  useRemoveOverlayPerson,
21
20
  } from "@/hooks/use-overlay-people";
22
-
23
- interface SearchResult {
24
- name: string;
25
- email: string;
26
- photoUrl?: string;
27
- }
28
-
29
- interface SearchResponse {
30
- results: SearchResult[];
31
- scopeRequired?: boolean;
32
- }
21
+ import {
22
+ filterPeopleResults,
23
+ mergePeopleResults,
24
+ usePeopleContacts,
25
+ usePeopleSearch,
26
+ } from "@/hooks/use-people";
33
27
 
34
28
  const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
35
29
 
@@ -44,12 +38,8 @@ export function PeopleSearchDialog({
44
38
  }: PeopleSearchDialogProps) {
45
39
  const t = useT();
46
40
  const [query, setQuery] = useState("");
47
- const [results, setResults] = useState<SearchResult[]>([]);
48
- const [scopeRequired, setScopeRequired] = useState(false);
49
- const [searching, setSearching] = useState(false);
41
+ const [searchQuery, setSearchQuery] = useState("");
50
42
  const [activeIndex, setActiveIndex] = useState(-1);
51
- const inputRef = useRef<HTMLInputElement>(null);
52
- const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
53
43
  const listRef = useRef<HTMLDivElement>(null);
54
44
  const shouldScrollActiveResultRef = useRef(false);
55
45
 
@@ -57,52 +47,61 @@ export function PeopleSearchDialog({
57
47
  const overlayPeople = Array.isArray(rawOverlayPeople) ? rawOverlayPeople : [];
58
48
  const addPerson = useAddOverlayPerson();
59
49
  const removePerson = useRemoveOverlayPerson();
50
+ const contacts = usePeopleContacts("directory", open);
51
+ const directorySearch = usePeopleSearch(searchQuery, open, "directory");
52
+
53
+ const overlayEmails = useMemo(
54
+ () => new Set(overlayPeople.map((p) => p.email.toLowerCase())),
55
+ [overlayPeople],
56
+ );
60
57
 
61
- const overlayEmails = new Set(overlayPeople.map((p) => p.email));
58
+ const results = useMemo(
59
+ () =>
60
+ filterPeopleResults(
61
+ mergePeopleResults(
62
+ contacts.data?.results,
63
+ directorySearch.data?.results,
64
+ ),
65
+ query,
66
+ new Set(),
67
+ ),
68
+ [contacts.data?.results, directorySearch.data?.results, query],
69
+ );
62
70
 
63
71
  // Selectable results (exclude already-added)
64
- const selectableResults = results.filter((r) => !overlayEmails.has(r.email));
72
+ const selectableResults = results.filter(
73
+ (r) => !overlayEmails.has(r.email.toLowerCase()),
74
+ );
65
75
 
66
- const search = useCallback(async (q: string) => {
67
- setSearching(true);
68
- try {
69
- const data = await callAction<SearchResponse>(
70
- "search-people",
71
- q ? { q, scope: "directory" } : { scope: "directory" },
72
- { method: "GET" },
73
- );
74
- setResults(data.results ?? []);
75
- setScopeRequired(data.scopeRequired ?? false);
76
- } catch {
77
- // ignore
78
- } finally {
79
- setSearching(false);
80
- }
81
- }, []);
76
+ const searching =
77
+ contacts.isLoading ||
78
+ contacts.isFetching ||
79
+ directorySearch.isLoading ||
80
+ directorySearch.isFetching;
81
+ const scopeRequired = Boolean(
82
+ contacts.data?.scopeRequired || directorySearch.data?.scopeRequired,
83
+ );
82
84
 
83
85
  useEffect(() => {
84
- if (debounceRef.current) clearTimeout(debounceRef.current);
85
- debounceRef.current = setTimeout(() => search(query), query ? 300 : 0);
86
- return () => {
87
- if (debounceRef.current) clearTimeout(debounceRef.current);
88
- };
89
- }, [query, search]);
86
+ const timeout = window.setTimeout(
87
+ () => setSearchQuery(query),
88
+ query.trim() ? 300 : 0,
89
+ );
90
+ return () => window.clearTimeout(timeout);
91
+ }, [query]);
90
92
 
91
93
  // Reset active index when results change
92
94
  useEffect(() => {
93
95
  setActiveIndex(results.length > 0 ? 0 : -1);
94
96
  }, [results]);
95
97
 
96
- // Load org contacts when dialog opens
97
98
  useEffect(() => {
98
99
  if (open) {
99
100
  setQuery("");
100
- setResults([]);
101
- setScopeRequired(false);
101
+ setSearchQuery("");
102
102
  setActiveIndex(-1);
103
- search("");
104
103
  }
105
- }, [open, search]);
104
+ }, [open]);
106
105
 
107
106
  function handleAdd(email: string, name?: string) {
108
107
  addPerson.mutate({ email, name });
@@ -145,7 +144,10 @@ export function PeopleSearchDialog({
145
144
  }
146
145
  // Otherwise, try adding as a typed email
147
146
  const trimmed = query.trim();
148
- if (EMAIL_REGEX.test(trimmed) && !overlayEmails.has(trimmed)) {
147
+ if (
148
+ EMAIL_REGEX.test(trimmed) &&
149
+ !overlayEmails.has(trimmed.toLowerCase())
150
+ ) {
149
151
  handleAdd(trimmed);
150
152
  setQuery("");
151
153
  }
@@ -154,7 +156,7 @@ export function PeopleSearchDialog({
154
156
 
155
157
  return (
156
158
  <Dialog open={open} onOpenChange={onOpenChange}>
157
- <DialogContent className="sm:max-w-[440px] gap-0 p-0">
159
+ <DialogContent className="sm:max-w-[440px] max-h-[calc(85vh-1rem)] gap-0 overflow-y-auto p-0 top-[15vh] !translate-y-0">
158
160
  <DialogHeader className="px-4 pt-4 pb-0">
159
161
  <DialogTitle className="text-base">
160
162
  {t("eventForm.people")}
@@ -165,7 +167,6 @@ export function PeopleSearchDialog({
165
167
  <div className="relative px-4 pt-3 pb-2">
166
168
  <IconSearch className="absolute left-7 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
167
169
  <input
168
- ref={inputRef}
169
170
  value={query}
170
171
  onChange={(e) => setQuery(e.target.value)}
171
172
  onKeyDown={handleKeyDown}
@@ -185,7 +186,9 @@ export function PeopleSearchDialog({
185
186
  className="max-h-48 overflow-y-auto border-t border-border"
186
187
  >
187
188
  {results.map((person) => {
188
- const alreadyAdded = overlayEmails.has(person.email);
189
+ const alreadyAdded = overlayEmails.has(
190
+ person.email.toLowerCase(),
191
+ );
189
192
  const selectableIdx = selectableResults.indexOf(person);
190
193
  const isActive = !alreadyAdded && selectableIdx === activeIndex;
191
194
  return (
@@ -367,7 +367,7 @@ const WeekEventCard = memo(function WeekEventCard({
367
367
  style={{
368
368
  ...style,
369
369
  left: `calc(${li.left}% + ${li.indent}px)`,
370
- width: `calc(${li.width}% - ${li.indent * 2 + 2}px)`,
370
+ width: `calc(${li.width}% - ${li.indent + 2}px)`,
371
371
  zIndex:
372
372
  isBeingDragged && isDragging
373
373
  ? 100
@@ -272,7 +272,10 @@ export function AppLayout({ children }: AppLayoutProps) {
272
272
 
273
273
  useEffect(() => {
274
274
  if (!hasAccounts) return;
275
- void prefetchPeopleContacts(queryClient);
275
+ void Promise.all([
276
+ prefetchPeopleContacts(queryClient),
277
+ prefetchPeopleContacts(queryClient, "directory"),
278
+ ]);
276
279
  }, [hasAccounts, queryClient]);
277
280
 
278
281
  useEffect(() => {