@manta-eu/react 0.4.0 → 0.5.0

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 (63) hide show
  1. package/README.md +1 -0
  2. package/dist/components/chat-view/Chat.d.ts +20 -0
  3. package/dist/components/chat-view/Chat.d.ts.map +1 -1
  4. package/dist/components/chat-view/Chat.js +51 -15
  5. package/dist/components/chat-view/Chat.js.map +1 -1
  6. package/dist/components/embedded-chat/index.d.ts +1 -1
  7. package/dist/components/embedded-chat/index.d.ts.map +1 -1
  8. package/dist/components/embedded-chat/index.js +1 -1
  9. package/dist/components/embedded-chat/index.js.map +1 -1
  10. package/dist/components/planning/ActioningView.d.ts.map +1 -1
  11. package/dist/components/planning/ActioningView.js +1 -1
  12. package/dist/components/planning/ActioningView.js.map +1 -1
  13. package/dist/components/planning/ExecutionTopBar.d.ts +1 -0
  14. package/dist/components/planning/ExecutionTopBar.d.ts.map +1 -1
  15. package/dist/components/planning/ExecutionTopBar.js +3 -2
  16. package/dist/components/planning/ExecutionTopBar.js.map +1 -1
  17. package/dist/components/planning/plan-detail-utils.d.ts +1 -0
  18. package/dist/components/planning/plan-detail-utils.d.ts.map +1 -1
  19. package/dist/components/planning/plan-detail-utils.js +5 -2
  20. package/dist/components/planning/plan-detail-utils.js.map +1 -1
  21. package/dist/components/tool-invocation-card/ToolOutputRenderer.d.ts.map +1 -1
  22. package/dist/components/tool-invocation-card/ToolOutputRenderer.js +5 -2
  23. package/dist/components/tool-invocation-card/ToolOutputRenderer.js.map +1 -1
  24. package/dist/hooks/use-connection-problems.d.ts.map +1 -1
  25. package/dist/hooks/useValidateActionPlan.d.ts.map +1 -1
  26. package/dist/lib/chat-types.d.ts +17 -0
  27. package/dist/lib/chat-types.d.ts.map +1 -1
  28. package/dist/lib/chat-types.js.map +1 -1
  29. package/dist/providers/ChatManagerProvider.d.ts +7 -0
  30. package/dist/providers/ChatManagerProvider.d.ts.map +1 -1
  31. package/dist/providers/ChatManagerProvider.js +129 -16
  32. package/dist/providers/ChatManagerProvider.js.map +1 -1
  33. package/dist/providers/chat-connection-context.d.ts +4 -1
  34. package/dist/providers/chat-connection-context.d.ts.map +1 -1
  35. package/dist/providers/chat-connection-context.js.map +1 -1
  36. package/dist/providers/chat-connection-lifecycle.d.ts +1 -0
  37. package/dist/providers/chat-connection-lifecycle.d.ts.map +1 -1
  38. package/dist/providers/chat-connection-lifecycle.js.map +1 -1
  39. package/dist/providers/chat-socket-auth.d.ts +3 -1
  40. package/dist/providers/chat-socket-auth.d.ts.map +1 -1
  41. package/dist/providers/chat-socket-auth.js +27 -9
  42. package/dist/providers/chat-socket-auth.js.map +1 -1
  43. package/dist/providers/chat-startup-timing.d.ts +14 -0
  44. package/dist/providers/chat-startup-timing.d.ts.map +1 -0
  45. package/dist/providers/chat-startup-timing.js +72 -0
  46. package/dist/providers/chat-startup-timing.js.map +1 -0
  47. package/dist/routes/plans/components/ActionPlansTableColumns.d.ts.map +1 -1
  48. package/dist/routes/plans/components/ActionPlansTableColumns.js +3 -3
  49. package/dist/routes/plans/components/ActionPlansTableColumns.js.map +1 -1
  50. package/package.json +7 -7
  51. package/src/components/chat-view/Chat.tsx +62 -15
  52. package/src/components/embedded-chat/index.tsx +9 -2
  53. package/src/components/planning/ActioningView.tsx +1 -0
  54. package/src/components/planning/ExecutionTopBar.tsx +14 -2
  55. package/src/components/planning/plan-detail-utils.tsx +6 -2
  56. package/src/components/tool-invocation-card/ToolOutputRenderer.tsx +5 -2
  57. package/src/lib/chat-types.ts +18 -0
  58. package/src/providers/ChatManagerProvider.tsx +166 -15
  59. package/src/providers/chat-connection-context.ts +4 -0
  60. package/src/providers/chat-connection-lifecycle.ts +1 -0
  61. package/src/providers/chat-socket-auth.ts +31 -12
  62. package/src/providers/chat-startup-timing.ts +89 -0
  63. package/src/routes/plans/components/ActionPlansTableColumns.tsx +3 -2
@@ -3,35 +3,55 @@ import { ChatContextKind, type ChatContext } from "@manta-eu/client/shared/agent
3
3
  import {
4
4
  chatIdSchema,
5
5
  chatManagerBasePath,
6
+ chatModeQuery,
6
7
  type ChatManagerClient,
8
+ type ConversationMode,
7
9
  type ConversationSummary,
8
10
  type DeleteConversationResult,
9
11
  type ListConversationsInput,
10
12
  } from "@manta-eu/client/shared/chat-manager";
11
13
  import { datastoreSocketQuery } from "@manta-eu/client/shared/datastore-path";
12
14
  import { chatIdToPath, newChatPath, type ChatUIMessage } from "@manta-eu/client/shared/index";
15
+ import { DEFAULT_CALL_TIMEOUT_MS } from "agents/client";
13
16
  import { useAgent } from "agents/react";
14
- import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
17
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
15
18
  import { matchPath, useLocation, useMatch, useNavigate } from "react-router";
16
19
  import { toast } from "sonner";
17
20
  import { match } from "ts-pattern";
21
+ import type { ChatStartupReport } from "../lib/chat-types";
18
22
  import { ChatManagerContext, type ChatManagerContextValue, type MessageHandoff } from "./chat-connection-context";
19
23
  import {
20
24
  createBlankConversationLifecycle,
21
25
  mapManagerConnectionState,
22
26
  type BlankConversationLifecycle,
23
27
  } from "./chat-connection-lifecycle";
24
- import { useChatSocketAuth } from "./chat-socket-auth";
28
+ import { isRequestedClose, useChatSocketAuth } from "./chat-socket-auth";
29
+ import {
30
+ chatStartupReport,
31
+ createChatStartupTimeline,
32
+ markChatStartup,
33
+ type ChatViewStartupPhase,
34
+ } from "./chat-startup-timing";
25
35
  import { useMantaClient } from "./index";
26
36
 
27
37
  export { useChatManagerConnection, type ChatManagerConnectionContextValue } from "./chat-connection-context";
28
38
 
39
+ export type { ChatStartupReport } from "../lib/chat-types";
40
+
41
+ export type { ChatViewStartupPhase } from "./chat-startup-timing";
42
+
29
43
  export type ChatManagerProviderProps = {
30
44
  onSelectPlan?: (planIri: string) => void;
31
45
  datastoreId: string | undefined;
46
+ // Fixed for the provider's lifetime: it picks the manager, so key the provider on it.
47
+ mode?: ConversationMode;
48
+ surface?: "embedded" | "app";
49
+ onStartupTiming?: (report: ChatStartupReport) => void;
32
50
  children: ReactNode;
33
51
  };
34
52
 
53
+ let chatStartupReports = 0;
54
+
35
55
  type ContextTransition =
36
56
  | { kind: "select"; context: ChatContext }
37
57
  | { kind: "start"; context: ChatContext; message: ChatUIMessage }
@@ -45,6 +65,20 @@ type ExpectedConversationClose = {
45
65
  conversationId: string;
46
66
  };
47
67
 
68
+ type ManagerOpenWaiter = {
69
+ resolve: () => void;
70
+ reject: (error: Error) => void;
71
+ timer: ReturnType<typeof setTimeout>;
72
+ };
73
+
74
+ function settleManagerOpenWaiters(waiters: Set<ManagerOpenWaiter>, settle: (waiter: ManagerOpenWaiter) => void) {
75
+ for (const waiter of waiters) {
76
+ clearTimeout(waiter.timer);
77
+ settle(waiter);
78
+ }
79
+ waiters.clear();
80
+ }
81
+
48
82
  function activeConversationFromSelection(selection: ConversationSelection | undefined) {
49
83
  return selection?.kind === "active" ? selection.conversation : undefined;
50
84
  }
@@ -57,9 +91,13 @@ function clearExpectedConversationClose(
57
91
  }
58
92
 
59
93
  export function ChatManagerProvider(props: ChatManagerProviderProps) {
60
- const { onSelectPlan, datastoreId, children } = props;
94
+ const { onSelectPlan, datastoreId, surface = "app", onStartupTiming, children } = props;
95
+ const [mode] = useState<ConversationMode>(props.mode ?? "datastore");
61
96
  const { mantaBaseUrl } = useMantaClient();
62
- const datastoreQuery = useMemo(() => datastoreSocketQuery(datastoreId), [datastoreId]);
97
+ const datastoreQuery = useMemo(
98
+ () => ({ ...datastoreSocketQuery(datastoreId), ...chatModeQuery(mode) }),
99
+ [datastoreId, mode],
100
+ );
63
101
  const navigate = useNavigate();
64
102
  const location = useLocation();
65
103
  const chatMatch = useMatch("/chat/:chatId");
@@ -91,7 +129,35 @@ export function ChatManagerProvider(props: ChatManagerProviderProps) {
91
129
  pathnameRef.current = location.pathname;
92
130
  routedConversationIdRef.current = routedConversationId;
93
131
 
94
- const socketAuth = useChatSocketAuth(datastoreQuery);
132
+ const [timeline] = useState(createChatStartupTimeline);
133
+ const onStartupTimingRef = useRef(onStartupTiming);
134
+ onStartupTimingRef.current = onStartupTiming;
135
+ const socketAuth = useChatSocketAuth(datastoreQuery, (phase) => markChatStartup(timeline, phase));
136
+ const authMode = "enabled" in socketAuth ? "ticket" : "cookie";
137
+ const socketKey = "enabled" in socketAuth && !socketAuth.enabled ? undefined : JSON.stringify(socketAuth.query);
138
+ const enabledSocketKeyRef = useRef<string>(undefined);
139
+ useEffect(() => {
140
+ if (socketKey === undefined || enabledSocketKeyRef.current === socketKey) return;
141
+ enabledSocketKeyRef.current = socketKey;
142
+ markChatStartup(timeline, "socket-enabled");
143
+ }, [socketKey, timeline]);
144
+
145
+ const reportedStartupRef = useRef(false);
146
+ const reportStartup = useCallback(() => {
147
+ if (reportedStartupRef.current) return;
148
+ const report = chatStartupReport(timeline, { surface, mode: authMode, openIndex: chatStartupReports + 1 });
149
+ if (!report) return;
150
+ reportedStartupRef.current = true;
151
+ chatStartupReports += 1;
152
+ try {
153
+ onStartupTimingRef.current?.(report);
154
+ } catch (error) {
155
+ console.error("onStartupTiming threw", error);
156
+ }
157
+ }, [surface, authMode, timeline]);
158
+
159
+ const managerOpenWaitersRef = useRef(new Set<ManagerOpenWaiter>());
160
+
95
161
  const managerAgent = useAgent<ChatManagerClient, ChatManagerClient["state"]>({
96
162
  agent: "ChatManager",
97
163
  basePath: chatManagerBasePath,
@@ -100,8 +166,56 @@ export function ChatManagerProvider(props: ChatManagerProviderProps) {
100
166
  ...("enabled" in socketAuth
101
167
  ? { enabled: socketAuth.enabled, shouldReconnectOnClose: socketAuth.shouldReconnectOnClose }
102
168
  : {}),
169
+ onOpen: () => {
170
+ markChatStartup(timeline, "socket-open");
171
+ reportStartup();
172
+ settleManagerOpenWaiters(managerOpenWaitersRef.current, (waiter) => waiter.resolve());
173
+ },
174
+ onClose: (event) => {
175
+ if (authMode === "cookie" && !isRequestedClose(event) && timeline.marks["socket-open"] === undefined) {
176
+ timeline.socketAttempts += 1;
177
+ }
178
+ },
103
179
  });
104
180
 
181
+ const managerAgentRef = useRef(managerAgent);
182
+ managerAgentRef.current = managerAgent;
183
+
184
+ const disposedRef = useRef(false);
185
+ useEffect(() => {
186
+ disposedRef.current = false;
187
+ return () => {
188
+ disposedRef.current = true;
189
+ };
190
+ }, []);
191
+
192
+ const waitForManagerOpen = useCallback((): Promise<void> => {
193
+ const agent = managerAgentRef.current;
194
+ if (agent.readyState === WebSocket.OPEN) return Promise.resolve();
195
+ if (agent.connectionError !== null && agent.readyState === WebSocket.CLOSED) {
196
+ return Promise.reject(new Error("The chat connection closed."));
197
+ }
198
+ return new Promise((resolve, reject) => {
199
+ const waiters = managerOpenWaitersRef.current;
200
+ const waiter: ManagerOpenWaiter = {
201
+ resolve,
202
+ reject,
203
+ timer: setTimeout(() => {
204
+ waiters.delete(waiter);
205
+ if (!disposedRef.current) reject(new Error("The chat connection did not open."));
206
+ }, DEFAULT_CALL_TIMEOUT_MS),
207
+ };
208
+ waiters.add(waiter);
209
+ });
210
+ }, []);
211
+
212
+ const markStartup = useCallback(
213
+ (phase: ChatViewStartupPhase) => {
214
+ if (markChatStartup(timeline, phase) && phase === "interactive") reportStartup();
215
+ },
216
+ [reportStartup, timeline],
217
+ );
218
+
105
219
  const listConversations = useCallback(
106
220
  (input: ListConversationsInput = {}): Promise<ConversationSummary[]> => managerAgent.stub.listConversations(input),
107
221
  [managerAgent],
@@ -166,10 +280,14 @@ export function ChatManagerProvider(props: ChatManagerProviderProps) {
166
280
 
167
281
  const sendBlankConversationMessage = useCallback(
168
282
  (message: ChatUIMessage): Promise<void> => {
169
- const lifecycle = blankConversationRef.current;
170
- if (lifecycle.materialization || lifecycle.conversationId) {
283
+ let lifecycle = blankConversationRef.current;
284
+ if (lifecycle.conversationId || lifecycle.creating) {
171
285
  return Promise.reject(new Error("A new chat is already starting."));
172
286
  }
287
+ if (lifecycle.materialization) {
288
+ lifecycle = createBlankConversationLifecycle();
289
+ blankConversationRef.current = lifecycle;
290
+ }
173
291
 
174
292
  setMessageHandoff({ message });
175
293
  function clearMessageHandoff() {
@@ -187,13 +305,23 @@ export function ChatManagerProvider(props: ChatManagerProviderProps) {
187
305
  }
188
306
  const materialization = (async () => {
189
307
  try {
190
- const conversation = await managerAgent.stub.createConversation({
191
- idempotencyKey: lifecycle.idempotencyKey,
192
- });
308
+ const deadline = performance.now() + DEFAULT_CALL_TIMEOUT_MS;
309
+ await waitForManagerOpen();
310
+ if (!isCurrentLifecycle()) {
311
+ clearMessageHandoff();
312
+ return;
313
+ }
314
+
315
+ lifecycle.creating = true;
316
+ const conversation = await managerAgentRef.current.call(
317
+ "createConversation",
318
+ [{ idempotencyKey: lifecycle.idempotencyKey }],
319
+ { timeout: Math.max(deadline - performance.now(), 1) },
320
+ );
193
321
 
194
322
  if (!isCurrentLifecycle()) {
195
323
  clearMessageHandoff();
196
- await managerAgent.stub.deleteConversation({ id: conversation.id });
324
+ await managerAgentRef.current.stub.deleteConversation({ id: conversation.id });
197
325
  return;
198
326
  }
199
327
 
@@ -213,13 +341,14 @@ export function ChatManagerProvider(props: ChatManagerProviderProps) {
213
341
  throw error;
214
342
  } finally {
215
343
  lifecycle.materialization = undefined;
344
+ lifecycle.creating = false;
216
345
  }
217
346
  })();
218
347
 
219
348
  lifecycle.materialization = materialization;
220
349
  return materialization;
221
350
  },
222
- [managerAgent, navigate],
351
+ [navigate, waitForManagerOpen],
223
352
  );
224
353
 
225
354
  const startNewChat = useCallback(() => {
@@ -234,8 +363,13 @@ export function ChatManagerProvider(props: ChatManagerProviderProps) {
234
363
  }
235
364
  }, [onNewChatPath, navigate]);
236
365
 
366
+ const handledPathnameRef = useRef(location.pathname);
367
+
237
368
  // `/` is still a blank chat in an app that gives its datastores no home page.
238
- useEffect(() => {
369
+ useLayoutEffect(() => {
370
+ if (handledPathnameRef.current === location.pathname) return;
371
+ handledPathnameRef.current = location.pathname;
372
+
239
373
  if (onNewChatPath || location.pathname === "/") {
240
374
  const preserveBlankLifecycle = preserveBlankLifecycleOnNextBlankChatRef.current;
241
375
  if (!preserveBlankLifecycle) {
@@ -247,6 +381,7 @@ export function ChatManagerProvider(props: ChatManagerProviderProps) {
247
381
  setConversationSelection(undefined);
248
382
  } else {
249
383
  preserveBlankLifecycleOnNextBlankChatRef.current = false;
384
+ setMessageHandoff((current) => (current?.conversationId ? current : undefined));
250
385
  }
251
386
  }, [location.pathname, onNewChatPath]);
252
387
 
@@ -394,6 +529,10 @@ export function ChatManagerProvider(props: ChatManagerProviderProps) {
394
529
  onSelectPlan(planIri);
395
530
  return;
396
531
  }
532
+ if (mode === "group") {
533
+ toast.info("Open this plan from its own datastore.");
534
+ return;
535
+ }
397
536
 
398
537
  const context = { kind: ChatContextKind.Plan, planGraphIri: planIri } as const;
399
538
  const source = activeConversationFromSelection(conversationSelectionRef.current);
@@ -412,12 +551,20 @@ export function ChatManagerProvider(props: ChatManagerProviderProps) {
412
551
  if (pendingPlanNavigationRef.current === navigation) pendingPlanNavigationRef.current = undefined;
413
552
  }
414
553
  },
415
- [navigate, onSelectPlan, reassignCurrentConversation],
554
+ [mode, navigate, onSelectPlan, reassignCurrentConversation],
416
555
  );
417
556
 
418
557
  const managerConnectionState = mapManagerConnectionState(managerAgent);
558
+
559
+ useEffect(() => {
560
+ if (managerAgent.connectionError === null) return;
561
+ const error = new Error("The chat connection closed.");
562
+ settleManagerOpenWaiters(managerOpenWaitersRef.current, (waiter) => waiter.reject(error));
563
+ }, [managerAgent.connectionError]);
564
+
419
565
  const selectedContext = activeConversation?.context ?? pendingContext;
420
- const isConversationLoading = routedConversationId !== undefined && activeConversation?.id !== routedConversationId;
566
+ const isConversationLoading =
567
+ invalidConversationRoute || (routedConversationId !== undefined && activeConversation?.id !== routedConversationId);
421
568
  const context = useMemo<ChatManagerContextValue>(
422
569
  () => ({
423
570
  managerConnection: {
@@ -428,6 +575,7 @@ export function ChatManagerProvider(props: ChatManagerProviderProps) {
428
575
  pendingMessage: messageHandoff?.message,
429
576
  isConversationLoading,
430
577
  connectionState: managerConnectionState,
578
+ mode,
431
579
  listConversations,
432
580
  deleteConversation,
433
581
  openConversation,
@@ -437,6 +585,7 @@ export function ChatManagerProvider(props: ChatManagerProviderProps) {
437
585
  startContextConversation,
438
586
  reassignCurrentConversation,
439
587
  handleSelectPlan,
588
+ markStartup,
440
589
  },
441
590
  session: {
442
591
  activeConversationId: activeConversation?.id,
@@ -451,11 +600,13 @@ export function ChatManagerProvider(props: ChatManagerProviderProps) {
451
600
  [
452
601
  activeConversation,
453
602
  deleteConversation,
603
+ mode,
454
604
  handleSelectPlan,
455
605
  isConversationLoading,
456
606
  listConversations,
457
607
  managerAgent,
458
608
  managerAgent.state,
609
+ markStartup,
459
610
  managerConnectionState,
460
611
  mantaBaseUrl.host,
461
612
  datastoreQuery,
@@ -1,6 +1,7 @@
1
1
  import type { ChatContext, SendAgentMessage } from "@manta-eu/client/shared/agent-types";
2
2
  import type {
3
3
  ChatManagerState,
4
+ ConversationMode,
4
5
  ConversationSummary,
5
6
  DeleteConversationResult,
6
7
  ListConversationsInput,
@@ -8,6 +9,7 @@ import type {
8
9
  import type { ChatUIMessage } from "@manta-eu/client/shared/index";
9
10
  import { createContext, useContext } from "react";
10
11
  import type { ConnectionState } from "../lib/chat-types";
12
+ import type { ChatViewStartupPhase } from "./chat-startup-timing";
11
13
 
12
14
  export type ChatManagerConnectionContextValue = {
13
15
  agent: { readyState: number; reconnect: () => void };
@@ -17,6 +19,7 @@ export type ChatManagerConnectionContextValue = {
17
19
  pendingMessage?: ChatUIMessage;
18
20
  isConversationLoading: boolean;
19
21
  connectionState: ConnectionState;
22
+ mode: ConversationMode;
20
23
  listConversations(input?: ListConversationsInput): Promise<ConversationSummary[]>;
21
24
  deleteConversation(id: string): Promise<DeleteConversationResult>;
22
25
  openConversation(id: string): void;
@@ -26,6 +29,7 @@ export type ChatManagerConnectionContextValue = {
26
29
  startContextConversation: (context: ChatContext, message: ChatUIMessage) => Promise<ConversationSummary | undefined>;
27
30
  reassignCurrentConversation: (context: ChatContext) => Promise<ConversationSummary | undefined>;
28
31
  handleSelectPlan: (planIri: string) => Promise<void>;
32
+ markStartup: (phase: ChatViewStartupPhase) => void;
29
33
  };
30
34
 
31
35
  export type MessageHandoff = {
@@ -40,6 +40,7 @@ export type BlankConversationLifecycle = {
40
40
  idempotencyKey: string;
41
41
  conversationId?: string;
42
42
  materialization?: Promise<void>;
43
+ creating?: boolean;
43
44
  };
44
45
 
45
46
  export function createBlankConversationLifecycle(): BlankConversationLifecycle {
@@ -1,9 +1,10 @@
1
- import { chatTicketPath, chatTicketQueryParam } from "@manta-eu/client/shared/chat-manager";
1
+ import { bearerFetch } from "@manta-eu/client";
2
+ import { chatModeQueryParam, chatTicketPath, chatTicketQueryParam } from "@manta-eu/client/shared/chat-manager";
2
3
  import { datastoreHeader, datastoreQueryParam } from "@manta-eu/client/shared/datastore-path";
3
4
  import { isPresent } from "@manta-eu/lib/present";
4
5
  import { isTerminalCloseEvent } from "agents/client";
5
6
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
- import { providerFetch } from "./manta-client-context";
7
+ import type { ChatSocketAuthPhase } from "./chat-startup-timing";
7
8
  import { useMantaClient } from "./MantaClientProvider";
8
9
 
9
10
  const tokenRefreshCheckMs = 30_000;
@@ -30,7 +31,7 @@ function ticketFrom(body: unknown): string | undefined {
30
31
  }
31
32
 
32
33
  // partysocket clears shouldReconnect before a close it was asked for: an unmount, or a socket being replaced.
33
- function isRequestedClose(event: CloseEvent): boolean {
34
+ export function isRequestedClose(event: CloseEvent): boolean {
34
35
  const target: unknown = event.target;
35
36
  return typeof target === "object" && target !== null && "shouldReconnect" in target && !target.shouldReconnect;
36
37
  }
@@ -39,12 +40,16 @@ function isRequestedClose(event: CloseEvent): boolean {
39
40
  * A ticket is spent on its first connect, and partysocket would reconnect to the same URL, so the socket never
40
41
  * reconnects by itself: each connect waits for a new ticket, and a dropped socket asks for one.
41
42
  */
42
- export function useChatSocketAuth(datastoreQuery: Record<string, string>): ChatSocketAuth {
43
+ export function useChatSocketAuth(
44
+ datastoreQuery: Record<string, string>,
45
+ onPhase?: (phase: ChatSocketAuthPhase) => void,
46
+ ): ChatSocketAuth {
43
47
  const client = useMantaClient();
44
48
  const { getAccessToken, mantaBaseUrl } = client;
45
- const authorizedFetch = providerFetch(client);
46
- const authorizedFetchRef = useRef(authorizedFetch);
47
- authorizedFetchRef.current = authorizedFetch;
49
+ const onPhaseRef = useRef(onPhase);
50
+ onPhaseRef.current = onPhase;
51
+ const getAccessTokenRef = useRef(getAccessToken);
52
+ getAccessTokenRef.current = getAccessToken;
48
53
  const hasAccessToken = getAccessToken !== undefined;
49
54
  const datastoreKey = JSON.stringify(datastoreQuery);
50
55
  const datastoreQueryRef = useRef(datastoreQuery);
@@ -70,11 +75,24 @@ export function useChatSocketAuth(datastoreQuery: Record<string, string>): ChatS
70
75
  const delay = reconnectDelaysMs[Math.min(Math.max(drops.current.count - 1, 0), reconnectDelaysMs.length - 1)];
71
76
  const timer = setTimeout(() => {
72
77
  const query = datastoreQueryRef.current;
73
- authorizedFetchRef
74
- .current(new URL(`/${chatTicketPath}`, mantaBaseUrl), {
75
- method: "POST",
76
- headers: datastoreHeader(query[datastoreQueryParam]),
77
- })
78
+ const getToken = getAccessTokenRef.current;
79
+ if (!getToken) return;
80
+ let sends = 0;
81
+ const requestTicket = bearerFetch(getToken, async (input, init) => {
82
+ sends += 1;
83
+ onPhaseRef.current?.("ticket-requested");
84
+ const response = await fetch(input, init);
85
+ if (response.status === 401 && sends === 1) onPhaseRef.current?.("token-requested");
86
+ return response;
87
+ });
88
+ const ticketUrl = new URL(`/${chatTicketPath}`, mantaBaseUrl);
89
+ const mode = query[chatModeQueryParam];
90
+ if (isPresent(mode)) ticketUrl.searchParams.set(chatModeQueryParam, mode);
91
+ onPhaseRef.current?.("token-requested");
92
+ requestTicket(ticketUrl, {
93
+ method: "POST",
94
+ headers: datastoreHeader(query[datastoreQueryParam]),
95
+ })
78
96
  .then(async (response) => {
79
97
  if (response.status >= 400 && response.status < 500 && !retriedRefusals.has(response.status)) {
80
98
  if (!cancelled) console.warn(`Manta refused a chat ticket (${response.status}); not asking again`);
@@ -84,6 +102,7 @@ export function useChatSocketAuth(datastoreQuery: Record<string, string>): ChatS
84
102
  if (!isPresent(ticket)) throw new Error(`Manta refused a chat ticket (${response.status})`);
85
103
  if (cancelled) return;
86
104
  ticketedAt.current = Date.now();
105
+ onPhaseRef.current?.("ticket-received");
87
106
  setTicketed({ datastoreKey, query: { ...query, [chatTicketQueryParam]: ticket } });
88
107
  })
89
108
  .catch((error: unknown) => {
@@ -0,0 +1,89 @@
1
+ import type { ChatStartupReport } from "../lib/chat-types";
2
+
3
+ export type ChatSocketAuthPhase = "token-requested" | "ticket-requested" | "ticket-received";
4
+
5
+ export type ChatViewStartupPhase = "chat-mounted" | "first-paint" | "interactive";
6
+
7
+ export type ChatStartupPhase = ChatSocketAuthPhase | "socket-enabled" | "socket-open" | ChatViewStartupPhase;
8
+
9
+ export type ChatStartupTimeline = {
10
+ startedAt: number;
11
+ marks: Partial<Record<ChatStartupPhase, number>>;
12
+ ticketAttempts: number;
13
+ socketAttempts: number;
14
+ };
15
+
16
+ export function createChatStartupTimeline(): ChatStartupTimeline {
17
+ return { startedAt: performance.now(), marks: {}, ticketAttempts: 0, socketAttempts: 0 };
18
+ }
19
+
20
+ const attemptEnds: Partial<Record<ChatStartupPhase, ChatStartupPhase>> = {
21
+ "token-requested": "socket-open",
22
+ "ticket-requested": "socket-open",
23
+ "ticket-received": "socket-open",
24
+ "socket-enabled": "socket-open",
25
+ "chat-mounted": "interactive",
26
+ };
27
+
28
+ const measures: Partial<Record<ChatStartupPhase, { name: string; from: ChatStartupPhase | "start" }>> = {
29
+ "ticket-requested": { name: "host-token", from: "token-requested" },
30
+ "ticket-received": { name: "ticket", from: "ticket-requested" },
31
+ "socket-open": { name: "socket", from: "socket-enabled" },
32
+ "chat-mounted": { name: "until-chat-mount", from: "start" },
33
+ "first-paint": { name: "until-first-paint", from: "chat-mounted" },
34
+ interactive: { name: "until-interactive", from: "chat-mounted" },
35
+ };
36
+
37
+ export function markChatStartup(timeline: ChatStartupTimeline, phase: ChatStartupPhase): boolean {
38
+ const attemptEnd = attemptEnds[phase];
39
+ if (timeline.marks[attemptEnd ?? phase] !== undefined) return false;
40
+ const now = performance.now();
41
+ timeline.marks[phase] = now - timeline.startedAt;
42
+ if (phase === "chat-mounted") delete timeline.marks["first-paint"];
43
+ if (phase === "token-requested") timeline.ticketAttempts += 1;
44
+ if (phase === "socket-enabled") timeline.socketAttempts += 1;
45
+ const measure = measures[phase];
46
+ const from = measure && (measure.from === "start" ? 0 : timeline.marks[measure.from]);
47
+ try {
48
+ performance.mark(`manta-chat:${phase}`);
49
+ if (measure && from !== undefined) {
50
+ performance.measure(`manta-chat:${measure.name}`, { start: timeline.startedAt + from, end: now });
51
+ }
52
+ } catch {}
53
+ return true;
54
+ }
55
+
56
+ function elapsed(from: number, to: number): number {
57
+ return Math.max(0, Math.round(to - from));
58
+ }
59
+
60
+ export function chatStartupReport(
61
+ timeline: ChatStartupTimeline,
62
+ input: Pick<ChatStartupReport, "surface" | "mode" | "openIndex">,
63
+ ): ChatStartupReport | undefined {
64
+ const { marks } = timeline;
65
+ const socketOpen = marks["socket-open"];
66
+ const chatMounted = marks["chat-mounted"];
67
+ const firstPaint = marks["first-paint"];
68
+ const interactive = marks.interactive;
69
+ if (socketOpen === undefined || chatMounted === undefined || firstPaint === undefined || interactive === undefined) {
70
+ return undefined;
71
+ }
72
+ const tokenRequested = marks["token-requested"];
73
+ const ticketRequested = marks["ticket-requested"];
74
+ const ticketReceived = marks["ticket-received"];
75
+ const ticketed = input.mode === "ticket" && ticketRequested !== undefined && ticketReceived !== undefined;
76
+ return {
77
+ ...input,
78
+ ...(ticketed && tokenRequested !== undefined ? { hostTokenMs: elapsed(tokenRequested, ticketRequested) } : {}),
79
+ ...(ticketed
80
+ ? { ticketMs: elapsed(ticketRequested, ticketReceived), ticketAttempts: Math.max(1, timeline.ticketAttempts) }
81
+ : {}),
82
+ socketMs: elapsed(marks["socket-enabled"] ?? 0, socketOpen),
83
+ socketAttempts: Math.max(1, timeline.socketAttempts),
84
+ chatMountMs: elapsed(0, chatMounted),
85
+ socketReadyAtOpen: socketOpen <= chatMounted,
86
+ firstPaintMs: elapsed(chatMounted, firstPaint),
87
+ interactiveMs: elapsed(chatMounted, interactive),
88
+ };
89
+ }
@@ -16,6 +16,7 @@ import {
16
16
  IntegrationIcon,
17
17
  integrationToChannel,
18
18
  isTerminalActionPlanState,
19
+ needsApproval,
19
20
  planStateVariant,
20
21
  statusLabel,
21
22
  } from "#components/planning/plan-detail-utils";
@@ -38,9 +39,9 @@ function planStateLabel(plan: ActionPlan, pendingApprovalCount: number): string
38
39
  return match({
39
40
  state: plan.state,
40
41
  hasCurrentFacts: plan.hasCurrentFacts,
41
- hasPendingApproval: pendingApprovalCount > 0,
42
+ needsApproval: needsApproval(plan.state, pendingApprovalCount),
42
43
  })
43
- .with({ state: GQLActionPlanState.Running, hasPendingApproval: true }, () => "Needs approval")
44
+ .with({ needsApproval: true }, () => "Needs approval")
44
45
  .with({ state: GQLActionPlanState.Draft, hasCurrentFacts: false }, () => "Not currently executable")
45
46
  .with({ state: GQLActionPlanState.Draft }, () => "Ready to launch")
46
47
  .otherwise(({ state: matchedState }) => statusLabel(matchedState));