@manta-eu/react 0.4.0 → 0.5.1

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 (96) hide show
  1. package/README.md +1 -0
  2. package/dist/components/chat-view/Chat.d.ts +27 -1
  3. package/dist/components/chat-view/Chat.d.ts.map +1 -1
  4. package/dist/components/chat-view/Chat.js +76 -18
  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/layouts/Sidebar.d.ts.map +1 -1
  11. package/dist/components/layouts/Sidebar.js +4 -2
  12. package/dist/components/layouts/Sidebar.js.map +1 -1
  13. package/dist/components/planning/ActioningView.d.ts.map +1 -1
  14. package/dist/components/planning/ActioningView.js +1 -1
  15. package/dist/components/planning/ActioningView.js.map +1 -1
  16. package/dist/components/planning/ExecutionTopBar.d.ts +1 -0
  17. package/dist/components/planning/ExecutionTopBar.d.ts.map +1 -1
  18. package/dist/components/planning/ExecutionTopBar.js +3 -2
  19. package/dist/components/planning/ExecutionTopBar.js.map +1 -1
  20. package/dist/components/planning/plan-detail-utils.d.ts +1 -0
  21. package/dist/components/planning/plan-detail-utils.d.ts.map +1 -1
  22. package/dist/components/planning/plan-detail-utils.js +5 -2
  23. package/dist/components/planning/plan-detail-utils.js.map +1 -1
  24. package/dist/components/sidebar/nav-user.js +1 -1
  25. package/dist/components/sidebar/nav-user.js.map +1 -1
  26. package/dist/components/tool-invocation-card/ToolOutputRenderer.d.ts.map +1 -1
  27. package/dist/components/tool-invocation-card/ToolOutputRenderer.js +5 -2
  28. package/dist/components/tool-invocation-card/ToolOutputRenderer.js.map +1 -1
  29. package/dist/explain-fact/rule-presentation.d.ts.map +1 -1
  30. package/dist/explain-fact/rule-presentation.js +1 -6
  31. package/dist/explain-fact/rule-presentation.js.map +1 -1
  32. package/dist/explain-fact/tree-model.d.ts +2 -1
  33. package/dist/explain-fact/tree-model.d.ts.map +1 -1
  34. package/dist/explain-fact/tree-model.js +17 -2
  35. package/dist/explain-fact/tree-model.js.map +1 -1
  36. package/dist/hooks/use-connection-problems.d.ts.map +1 -1
  37. package/dist/hooks/use-manual-actions.js +1 -1
  38. package/dist/hooks/use-manual-actions.js.map +1 -1
  39. package/dist/hooks/useAuth.d.ts +1 -0
  40. package/dist/hooks/useAuth.d.ts.map +1 -1
  41. package/dist/hooks/useAuth.js +2 -1
  42. package/dist/hooks/useAuth.js.map +1 -1
  43. package/dist/hooks/useValidateActionPlan.d.ts.map +1 -1
  44. package/dist/lib/chat-types.d.ts +17 -0
  45. package/dist/lib/chat-types.d.ts.map +1 -1
  46. package/dist/lib/chat-types.js.map +1 -1
  47. package/dist/lib/datastore-route-table.d.ts +1 -0
  48. package/dist/lib/datastore-route-table.d.ts.map +1 -1
  49. package/dist/lib/datastore-route-table.js +1 -0
  50. package/dist/lib/datastore-route-table.js.map +1 -1
  51. package/dist/providers/AuthContext.d.ts +1 -0
  52. package/dist/providers/AuthContext.d.ts.map +1 -1
  53. package/dist/providers/AuthContext.js.map +1 -1
  54. package/dist/providers/ChatManagerProvider.d.ts +7 -0
  55. package/dist/providers/ChatManagerProvider.d.ts.map +1 -1
  56. package/dist/providers/ChatManagerProvider.js +129 -16
  57. package/dist/providers/ChatManagerProvider.js.map +1 -1
  58. package/dist/providers/chat-connection-context.d.ts +4 -1
  59. package/dist/providers/chat-connection-context.d.ts.map +1 -1
  60. package/dist/providers/chat-connection-context.js.map +1 -1
  61. package/dist/providers/chat-connection-lifecycle.d.ts +1 -0
  62. package/dist/providers/chat-connection-lifecycle.d.ts.map +1 -1
  63. package/dist/providers/chat-connection-lifecycle.js.map +1 -1
  64. package/dist/providers/chat-socket-auth.d.ts +3 -1
  65. package/dist/providers/chat-socket-auth.d.ts.map +1 -1
  66. package/dist/providers/chat-socket-auth.js +27 -9
  67. package/dist/providers/chat-socket-auth.js.map +1 -1
  68. package/dist/providers/chat-startup-timing.d.ts +14 -0
  69. package/dist/providers/chat-startup-timing.d.ts.map +1 -0
  70. package/dist/providers/chat-startup-timing.js +72 -0
  71. package/dist/providers/chat-startup-timing.js.map +1 -0
  72. package/dist/routes/plans/components/ActionPlansTableColumns.d.ts.map +1 -1
  73. package/dist/routes/plans/components/ActionPlansTableColumns.js +3 -3
  74. package/dist/routes/plans/components/ActionPlansTableColumns.js.map +1 -1
  75. package/package.json +7 -7
  76. package/src/components/chat-view/Chat.tsx +122 -20
  77. package/src/components/embedded-chat/index.tsx +9 -2
  78. package/src/components/layouts/Sidebar.tsx +3 -2
  79. package/src/components/planning/ActioningView.tsx +1 -0
  80. package/src/components/planning/ExecutionTopBar.tsx +14 -2
  81. package/src/components/planning/plan-detail-utils.tsx +6 -2
  82. package/src/components/sidebar/nav-user.tsx +1 -1
  83. package/src/components/tool-invocation-card/ToolOutputRenderer.tsx +5 -2
  84. package/src/explain-fact/rule-presentation.ts +1 -6
  85. package/src/explain-fact/tree-model.ts +23 -1
  86. package/src/hooks/use-manual-actions.ts +1 -1
  87. package/src/hooks/useAuth.ts +3 -1
  88. package/src/lib/chat-types.ts +18 -0
  89. package/src/lib/datastore-route-table.ts +2 -0
  90. package/src/providers/AuthContext.ts +1 -0
  91. package/src/providers/ChatManagerProvider.tsx +166 -15
  92. package/src/providers/chat-connection-context.ts +4 -0
  93. package/src/providers/chat-connection-lifecycle.ts +1 -0
  94. package/src/providers/chat-socket-auth.ts +31 -12
  95. package/src/providers/chat-startup-timing.ts +89 -0
  96. package/src/routes/plans/components/ActionPlansTableColumns.tsx +3 -2
@@ -6,6 +6,7 @@ import { cn } from "#lib/utils";
6
6
  type ExecutionTopBarProps = {
7
7
  planName: string;
8
8
  status: ExecutionStatus;
9
+ pendingManualActionCount: number;
9
10
  workflowId: string | null;
10
11
  triggerError: string | null;
11
12
  onExecute?: (dryRun: boolean) => void;
@@ -38,8 +39,19 @@ const statusConfig: Record<
38
39
  };
39
40
 
40
41
  export function ExecutionTopBar(props: ExecutionTopBarProps) {
41
- const { planName, status, onExecute, onRerun, isExecuteDialogOpen, onExecuteDialogOpenChange, isDryRun } = props;
42
- const config = statusConfig[status];
42
+ const {
43
+ planName,
44
+ status,
45
+ pendingManualActionCount,
46
+ onExecute,
47
+ onRerun,
48
+ isExecuteDialogOpen,
49
+ onExecuteDialogOpenChange,
50
+ isDryRun,
51
+ } = props;
52
+
53
+ const shownStatus = status === "completed" && !isDryRun && pendingManualActionCount > 0 ? "needs-approval" : status;
54
+ const config = statusConfig[shownStatus];
43
55
  const isTerminal = status === "completed" || status === "failed";
44
56
  const displayLabel = (isDryRun && config.dryRunLabel) || config.label;
45
57
 
@@ -77,10 +77,14 @@ export function statusLabel(status: ActionPlanStatus): string {
77
77
  .exhaustive();
78
78
  }
79
79
 
80
+ export function needsApproval(state: ActionPlanStatus, pendingApprovalCount: number): boolean {
81
+ return pendingApprovalCount > 0 && (state === GQLActionPlanState.Running || state === GQLActionPlanState.Completed);
82
+ }
83
+
80
84
  export function planStateVariant(state: ActionPlanStatus, pendingApprovalCount = 0): BadgeVariant {
81
- return match({ state, hasPendingApproval: pendingApprovalCount > 0 })
85
+ return match({ state, needsApproval: needsApproval(state, pendingApprovalCount) })
82
86
  .returnType<BadgeVariant>()
83
- .with({ state: GQLActionPlanState.Running, hasPendingApproval: true }, () => "warning")
87
+ .with({ needsApproval: true }, () => "warning")
84
88
  .with({ state: GQLActionPlanState.Running }, () => "info")
85
89
  .with({ state: GQLActionPlanState.Completed }, () => "success")
86
90
  .with({ state: GQLActionPlanState.Failed }, () => "destructive")
@@ -83,7 +83,7 @@ export function NavUser(args: NavUserArgs) {
83
83
  {pickerSlot}
84
84
  {topSlot}
85
85
  {(pickerSlot || topSlot) && <DropdownSeparator />}
86
- <DropdownGroup className="p-2">
86
+ <DropdownGroup className={children ? "px-2 pt-2" : "p-2"}>
87
87
  <DropdownItem
88
88
  className="p-1.5 flex flex-row items-center gap-1.5 typography-caption h-11"
89
89
  onClick={onNavigateToAccount}
@@ -1,3 +1,4 @@
1
+ import { withChatModeQueryParam } from "@manta-eu/client/shared/chat-manager";
1
2
  import { withDatastoreQueryParam } from "@manta-eu/client/shared/datastore-path";
2
3
  import { isShowEntitiesResult } from "@manta-eu/client/shared/tools";
3
4
  import {
@@ -5,7 +6,7 @@ import {
5
6
  type WorkspaceArtifact,
6
7
  } from "@manta-eu/client/shared/workspace-artifacts";
7
8
  import type { ToolUIPart } from "ai";
8
- import { useCallback } from "react";
9
+ import { useCallback, useContext } from "react";
9
10
  import { match } from "ts-pattern";
10
11
  import { FileMessagePart } from "../chat-view/FileMessagePart";
11
12
  import { ActionPlanShowcaseCard } from "./ActionPlanShowcaseCard";
@@ -45,6 +46,7 @@ import {
45
46
  } from "#components/chart/index";
46
47
  import { decodeChartEntities } from "#lib/decode-chart-entities";
47
48
  import { cn } from "#lib/utils";
49
+ import { ChatManagerContext } from "#providers/chat-connection-context";
48
50
  import { useChatManagerConnection } from "#providers/ChatManagerProvider";
49
51
  import { useMantaClient } from "#providers/MantaClientProvider";
50
52
 
@@ -129,12 +131,13 @@ function WorkspaceArtifactResultDisplay(props: WorkspaceArtifactResultDisplayPro
129
131
  const { result } = props;
130
132
 
131
133
  const { datastoreId } = useMantaClient();
134
+ const mode = useContext(ChatManagerContext)?.managerConnection.mode;
132
135
 
133
136
  return (
134
137
  <FileMessagePart
135
138
  part={{
136
139
  type: "file",
137
- url: withDatastoreQueryParam(result.url, datastoreId),
140
+ url: withChatModeQueryParam(withDatastoreQueryParam(result.url, datastoreId), mode),
138
141
  mediaType: result.mediaType,
139
142
  filename: result.filename,
140
143
  }}
@@ -20,6 +20,7 @@ import { isPresent } from "@manta-eu/lib/present";
20
20
  import { match, P } from "ts-pattern";
21
21
  import { parseDatalogRuleCached, parseGroundedDatalogRuleCached } from "../rules/datalog-cache";
22
22
  import {
23
+ explainRuleAst,
23
24
  getRuleBodyTerms,
24
25
  kindForBodyFormula,
25
26
  kindForSyntheticTerm,
@@ -362,12 +363,6 @@ function ruleNameTermFromHeadText(headText: string): string {
362
363
  return predicate.trim();
363
364
  }
364
365
 
365
- // Explain payloads echo rules as the datastore serializes them (bare ON, atom-free aggregates),
366
- // so authored text falls through to the expanded grammar when the strict parse fails.
367
- function explainRuleAst(rule: string): DatalogRule | undefined {
368
- return parseDatalogRuleCached(rule).ast ?? parseGroundedDatalogRuleCached(rule).ast ?? undefined;
369
- }
370
-
371
366
  function collectBindings(ast: DatalogRule, groundedAst: DatalogRule): VariableBinding[] {
372
367
  const bindings = new Map<string, GroundedBinding>();
373
368
  const predicates = new Map<string, string>();
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  classAssertionFromAtom,
3
+ formulaSourceTexts,
3
4
  isDatalogAtom,
4
5
  isLiteralTerm,
5
6
  isVariableTerm,
@@ -11,6 +12,7 @@ import {
11
12
  type DatalogAtom,
12
13
  type DatalogBodyFormula,
13
14
  type DatalogExpression,
15
+ type DatalogRule,
14
16
  } from "@manta-eu/datalog-parser";
15
17
  import { isPresent } from "@manta-eu/lib/present";
16
18
  import { countBy } from "ramda";
@@ -472,13 +474,33 @@ export function ruleHeadText(rule: string): string {
472
474
  );
473
475
  }
474
476
 
477
+ // Explain payloads echo rules as the datastore serializes them (bare ON, atom-free aggregates),
478
+ // so authored text falls through to the expanded grammar when the strict parse fails.
479
+ export function explainRuleAst(rule: string): DatalogRule | undefined {
480
+ return parseDatalogRuleCached(rule).ast ?? parseGroundedDatalogRuleCached(rule).ast ?? undefined;
481
+ }
482
+
483
+ function parsedSourceTexts(rule: string) {
484
+ const parsed = explainRuleAst(rule);
485
+
486
+ return parsed ? formulaSourceTexts(parsed) : undefined;
487
+ }
488
+
475
489
  export function ruleHeadTerms(rule: string): string[] {
490
+ const parsed = parsedSourceTexts(rule);
491
+
492
+ if (parsed) return parsed.head;
493
+
476
494
  return splitTopLevel(ruleHeadText(rule), ",")
477
495
  .map((part) => part.trim())
478
496
  .filter(isPresent);
479
497
  }
480
498
 
481
499
  export function getRuleBodyTerms(rule: string): string[] {
500
+ const parsed = parsedSourceTexts(rule);
501
+
502
+ if (parsed) return parsed.body;
503
+
482
504
  const body = bodyText(rule);
483
505
 
484
506
  if (!body) return [];
@@ -624,7 +646,7 @@ function aggregateOutputsOf(
624
646
  }
625
647
 
626
648
  function ruleBodyFormulas(rule: string, expectedCount: number): DatalogBodyFormula[] | undefined {
627
- const ast = parseDatalogRuleCached(rule).ast ?? parseGroundedDatalogRuleCached(rule).ast ?? undefined;
649
+ const ast = explainRuleAst(rule);
628
650
 
629
651
  if (!ast || ast.body.length !== expectedCount) return undefined;
630
652
 
@@ -74,7 +74,7 @@ export function toManualAction(entity: ManualActionEntity, options: ManualAction
74
74
  assignees: entity.assignee.map(String),
75
75
  label: entity.label || null,
76
76
  description: entity.description || null,
77
- observedValue: entity.observedValue || null,
77
+ observedValue: typeof entity.observedValue === "string" ? entity.observedValue || null : null,
78
78
  aboutIri: entity.about ?? null,
79
79
  retryable: String(entity.retryable) === "true",
80
80
  defaultResolution: normalizeResolution(entity.defaultResolution ?? undefined),
@@ -65,6 +65,8 @@ const orgRoleSchema = z.object({
65
65
  capabilities: z.array(z.string()).transform((values) => values.filter(isCapability)),
66
66
  });
67
67
 
68
+ export const userContextQueryKey = ["user-context"] as const;
69
+
68
70
  export const contextResponseSchema = z.object({
69
71
  datastoreAccessGranted: z.boolean().default(true),
70
72
  user: z.object({
@@ -197,7 +199,7 @@ export function useAuth(): UseAuthResult {
197
199
  isLoading: isContextLoading,
198
200
  error: contextError,
199
201
  } = useQuery({
200
- queryKey: ["user-context", datastoreId],
202
+ queryKey: [...userContextQueryKey, datastoreId],
201
203
  queryFn: async ({ signal }) => {
202
204
  const res = await authorizedFetch(new URL("/graphql", mantaBaseUrl), {
203
205
  method: "POST",
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable import/order -- each type is imported beside the re-export it exists for, which is
2
2
  how this file is organized; they are type-only, so the positions have no runtime meaning. */
3
+ import type { ConversationMode } from "@manta-eu/client/shared/chat-manager";
3
4
  import type { MantaModelId } from "@manta-eu/client/shared/model";
4
5
  import type { ReactNode } from "react";
5
6
  import type { Datastore } from "#ldo-ui";
@@ -26,6 +27,21 @@ export type CopilotMenuDefaults = {
26
27
 
27
28
  export type ChatDraft = { id: string; text: string };
28
29
 
30
+ export type ChatStartupReport = {
31
+ surface: "embedded" | "app";
32
+ mode: "ticket" | "cookie";
33
+ openIndex: number;
34
+ hostTokenMs?: number;
35
+ ticketMs?: number;
36
+ ticketAttempts?: number;
37
+ socketMs: number;
38
+ socketAttempts: number;
39
+ chatMountMs: number;
40
+ socketReadyAtOpen: boolean;
41
+ firstPaintMs: number;
42
+ interactiveMs: number;
43
+ };
44
+
29
45
  // NOTE: Adding to this is a breaking change to library clients
30
46
  export type AuthenticatedChatProps = {
31
47
  datastore?: Datastore;
@@ -39,6 +55,7 @@ export type AuthenticatedChatProps = {
39
55
  collapsible?: boolean;
40
56
  isCollapsed?: boolean;
41
57
  pageContext?: PageContext;
58
+ mode?: ConversationMode;
42
59
  allowedModels?: MantaModelId[];
43
60
  initialPrompt?: string;
44
61
  initialDraft?: ChatDraft;
@@ -48,6 +65,7 @@ export type AuthenticatedChatProps = {
48
65
  assistantIcon?: AssistantIcon;
49
66
  mark?: ReactNode;
50
67
  renderMenuItems?: (defaults: CopilotMenuDefaults) => ReactNode[];
68
+ onStartupTiming?: (report: ChatStartupReport) => void;
51
69
  };
52
70
 
53
71
  export type ResolvedChatProps = AuthenticatedChatProps & {
@@ -19,6 +19,7 @@ export type RouteElements = {
19
19
  Team?: ReactNode;
20
20
  CustomApps?: ReactNode;
21
21
  CustomAppUsers?: ReactNode;
22
+ Profile?: ReactNode;
22
23
  DataExplorerLayout?: ReactNode;
23
24
  DataExplorerIndex?: ReactNode;
24
25
  DataExplorerRecent?: ReactNode;
@@ -48,6 +49,7 @@ export const globalRoutes: RouteDefinition[] = [
48
49
  { path: "/shopify/connect", render: (e) => e.ShopifyConnect || notFoundElement(e) },
49
50
  { path: "/custom-apps", render: (e) => e.CustomApps || notFoundElement(e) },
50
51
  { path: "/custom-apps/:clientId/users", render: (e) => e.CustomAppUsers || notFoundElement(e) },
52
+ { path: "/profile", render: (e) => e.Profile || notFoundElement(e) },
51
53
  ];
52
54
 
53
55
  export const datastoreRoutes: RouteDefinition[] = [
@@ -20,6 +20,7 @@ export type AuthContextValue = Pick<
20
20
  user: User;
21
21
  // Only Manta's own web app may list the apps connected to someone; any other host's token is refused.
22
22
  canDisconnectCustomApps?: boolean;
23
+ hasProfilePage?: boolean;
23
24
  };
24
25
 
25
26
  export const AuthContext = createContext<AuthContextValue | undefined>(undefined);
@@ -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 {