@btst/stack 4.1.1 → 4.2.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 (100) hide show
  1. package/README.md +2 -2
  2. package/dist/packages/stack/src/plugins/ai-chat/api/operations.cjs +15 -9
  3. package/dist/packages/stack/src/plugins/ai-chat/api/operations.mjs +16 -10
  4. package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.cjs +28 -0
  5. package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.mjs +29 -2
  6. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.cjs +5 -0
  7. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.mjs +5 -0
  8. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.cjs +91 -8
  9. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +91 -8
  10. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.cjs +76 -3
  11. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.mjs +80 -7
  12. package/dist/packages/stack/src/plugins/ai-chat/client/components/tool-call-display.cjs +17 -0
  13. package/dist/packages/stack/src/plugins/ai-chat/client/components/tool-call-display.mjs +18 -1
  14. package/dist/packages/stack/src/plugins/ai-chat/client/context/chat-analytics.cjs +42 -0
  15. package/dist/packages/stack/src/plugins/ai-chat/client/context/chat-analytics.mjs +39 -0
  16. package/dist/packages/stack/src/plugins/ai-chat/client/plugin.cjs +2 -1
  17. package/dist/packages/stack/src/plugins/ai-chat/client/plugin.mjs +2 -1
  18. package/dist/plugins/ai-chat/api/index.d.cts +2 -2
  19. package/dist/plugins/ai-chat/api/index.d.mts +2 -2
  20. package/dist/plugins/ai-chat/api/index.d.ts +2 -2
  21. package/dist/plugins/ai-chat/client/components/index.d.cts +2 -1
  22. package/dist/plugins/ai-chat/client/components/index.d.mts +2 -1
  23. package/dist/plugins/ai-chat/client/components/index.d.ts +2 -1
  24. package/dist/plugins/ai-chat/client/context/page-ai-context.cjs +4 -0
  25. package/dist/plugins/ai-chat/client/context/page-ai-context.d.cts +6 -2
  26. package/dist/plugins/ai-chat/client/context/page-ai-context.d.mts +6 -2
  27. package/dist/plugins/ai-chat/client/context/page-ai-context.d.ts +6 -2
  28. package/dist/plugins/ai-chat/client/context/page-ai-context.mjs +4 -1
  29. package/dist/plugins/ai-chat/client/hooks/index.d.cts +2 -2
  30. package/dist/plugins/ai-chat/client/hooks/index.d.mts +2 -2
  31. package/dist/plugins/ai-chat/client/hooks/index.d.ts +2 -2
  32. package/dist/plugins/ai-chat/client/index.d.cts +7 -3
  33. package/dist/plugins/ai-chat/client/index.d.mts +7 -3
  34. package/dist/plugins/ai-chat/client/index.d.ts +7 -3
  35. package/dist/plugins/ai-chat/query-keys.d.cts +1 -1
  36. package/dist/plugins/ai-chat/query-keys.d.mts +1 -1
  37. package/dist/plugins/ai-chat/query-keys.d.ts +1 -1
  38. package/dist/plugins/blog/api/index.d.cts +1 -1
  39. package/dist/plugins/blog/api/index.d.mts +1 -1
  40. package/dist/plugins/blog/api/index.d.ts +1 -1
  41. package/dist/plugins/blog/query-keys.d.cts +1 -1
  42. package/dist/plugins/blog/query-keys.d.mts +1 -1
  43. package/dist/plugins/blog/query-keys.d.ts +1 -1
  44. package/dist/plugins/cms/api/index.d.cts +1 -1
  45. package/dist/plugins/cms/api/index.d.mts +1 -1
  46. package/dist/plugins/cms/api/index.d.ts +1 -1
  47. package/dist/plugins/cms/query-keys.d.cts +1 -1
  48. package/dist/plugins/cms/query-keys.d.mts +1 -1
  49. package/dist/plugins/cms/query-keys.d.ts +1 -1
  50. package/dist/plugins/form-builder/api/index.d.cts +1 -1
  51. package/dist/plugins/form-builder/api/index.d.mts +1 -1
  52. package/dist/plugins/form-builder/api/index.d.ts +1 -1
  53. package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
  54. package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
  55. package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
  56. package/dist/plugins/form-builder/query-keys.d.cts +1 -1
  57. package/dist/plugins/form-builder/query-keys.d.mts +1 -1
  58. package/dist/plugins/form-builder/query-keys.d.ts +1 -1
  59. package/dist/plugins/ui-builder/client/hooks/index.d.cts +1 -1
  60. package/dist/plugins/ui-builder/client/hooks/index.d.mts +1 -1
  61. package/dist/plugins/ui-builder/client/hooks/index.d.ts +1 -1
  62. package/dist/plugins/ui-builder/client/index.d.cts +1 -1
  63. package/dist/plugins/ui-builder/client/index.d.mts +1 -1
  64. package/dist/plugins/ui-builder/client/index.d.ts +1 -1
  65. package/dist/plugins/ui-builder/query-keys.d.cts +1 -1
  66. package/dist/plugins/ui-builder/query-keys.d.mts +1 -1
  67. package/dist/plugins/ui-builder/query-keys.d.ts +1 -1
  68. package/dist/shared/{stack.B5yPgHsp.d.mts → stack.B7Mj74BQ.d.mts} +18 -2
  69. package/dist/shared/{stack.rVF0f509.d.mts → stack.C-eQN_Jt.d.ts} +60 -4
  70. package/dist/shared/{stack.rVF0f509.d.ts → stack.C5FZr-jJ.d.mts} +60 -4
  71. package/dist/shared/{stack.3Sr6qRtT.d.ts → stack.DBH1Bjko.d.ts} +18 -2
  72. package/dist/shared/{stack.C4oVW2qK.d.cts → stack.Dgt3MCtW.d.cts} +18 -2
  73. package/dist/shared/{stack.rVF0f509.d.cts → stack.xAiy2VtO.d.cts} +60 -4
  74. package/package.json +1 -1
  75. package/src/plugins/ai-chat/__tests__/authorization.test.ts +106 -1
  76. package/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx +4 -1
  77. package/src/plugins/ai-chat/__tests__/page-content.test.ts +177 -0
  78. package/src/plugins/ai-chat/__tests__/page-widget.test.tsx +573 -0
  79. package/src/plugins/ai-chat/api/operations.ts +29 -13
  80. package/src/plugins/ai-chat/api/page-tools.ts +56 -2
  81. package/src/plugins/ai-chat/api/plugin.ts +13 -0
  82. package/src/plugins/ai-chat/client/analytics.ts +35 -0
  83. package/src/plugins/ai-chat/client/components/chat-interface.tsx +113 -10
  84. package/src/plugins/ai-chat/client/components/chat-layout.tsx +118 -7
  85. package/src/plugins/ai-chat/client/components/tool-call-display.tsx +29 -1
  86. package/src/plugins/ai-chat/client/context/chat-analytics.tsx +46 -0
  87. package/src/plugins/ai-chat/client/context/page-ai-context.tsx +9 -1
  88. package/src/plugins/ai-chat/client/index.ts +2 -0
  89. package/src/plugins/ai-chat/client/localization/index.ts +8 -0
  90. package/src/plugins/ai-chat/client/overrides.ts +5 -0
  91. package/src/plugins/ai-chat/client/plugin.tsx +3 -0
  92. package/dist/shared/{stack.lsbdKRj7.d.mts → stack.B8PG21zX.d.mts} +16 -16
  93. package/dist/shared/{stack.BuxUQqL8.d.ts → stack.CGmh-Lpe.d.ts} +12 -12
  94. package/dist/shared/{stack.BggTHKlo.d.mts → stack.CTneIW0u.d.mts} +2 -2
  95. package/dist/shared/{stack.CtrnoQgH.d.cts → stack.Ce6V7eDj.d.cts} +2 -2
  96. package/dist/shared/{stack.BMlTZnZp.d.ts → stack.CyalJ0j6.d.ts} +16 -16
  97. package/dist/shared/{stack.lPKwd_v0.d.ts → stack.D5b9l24t.d.ts} +2 -2
  98. package/dist/shared/{stack.BV0uNV4f.d.cts → stack.D6Pg-ufX.d.cts} +16 -16
  99. package/dist/shared/{stack.Egr3RWhA.d.mts → stack.Doy2_wgl.d.mts} +12 -12
  100. package/dist/shared/{stack.CX83YNuS.d.cts → stack.zch70Loj.d.cts} +12 -12
@@ -17,6 +17,7 @@ import { createApiClient } from '@btst/stack/plugins/client';
17
17
  import { aiChatIdentityKey, createAiChatQueryKeys } from '../../../../../../../plugins/ai-chat/query-keys.mjs';
18
18
  import { useAiChatIdentityPartition, useConversation, useConversations } from '../hooks/chat-hooks.mjs';
19
19
  import { usePageAIContext } from '../../../../../../../plugins/ai-chat/client/context/page-ai-context.mjs';
20
+ import { useChatAnalytics } from '../context/chat-analytics.mjs';
20
21
  import { navigateAiChatCrossOrigin } from '../navigation.mjs';
21
22
 
22
23
  function reconcilePersistedMessageIds(messages, persistedMessages) {
@@ -129,6 +130,8 @@ function PermissionedChatMessage({
129
130
  conversationId,
130
131
  ownerId,
131
132
  messageId,
133
+ toolNames,
134
+ routeName,
132
135
  ...messageProps
133
136
  }) {
134
137
  if (!action) return /* @__PURE__ */ jsx(ChatMessage, { ...messageProps });
@@ -140,6 +143,8 @@ function PermissionedChatMessage({
140
143
  conversationId,
141
144
  ownerId,
142
145
  messageId,
146
+ toolNames,
147
+ routeName,
143
148
  children: (allowed) => /* @__PURE__ */ jsx(
144
149
  ChatMessage,
145
150
  {
@@ -184,6 +189,10 @@ function ChatInterface({
184
189
  const siteBasePath = plugins?.aiChat?.site.basePath ?? stackBasePath;
185
190
  const isPublicMode = mode === "public";
186
191
  const pageAIContext = usePageAIContext();
192
+ const effectiveToolNames = [
193
+ ...Object.keys(pageAIContext?.clientTools ?? {}),
194
+ ...plugins?.aiChat?.config?.pageContent ? ["readPage"] : []
195
+ ];
187
196
  const tr = useAiChatTranslation(customLocalization);
188
197
  const queryClient = useQueryClient(stackQueryClient);
189
198
  const identityPartition = useAiChatIdentityPartition();
@@ -328,6 +337,29 @@ function ChatInterface({
328
337
  });
329
338
  }, [browserHeaders, credentials, isPublicMode, resolvedApiPath]);
330
339
  const addToolOutputRef = useRef(null);
340
+ const mounted = useRef(true);
341
+ const track = useChatAnalytics();
342
+ const inputKind = useRef("typed");
343
+ const responseStarted = useRef(null);
344
+ const trackSubmission = useCallback(
345
+ (kind, inputLength, messageCount, attachmentCount = 0) => {
346
+ responseStarted.current = Date.now();
347
+ track({
348
+ type: "message_submitted",
349
+ inputKind: kind,
350
+ inputLength,
351
+ messageCount,
352
+ attachmentCount
353
+ });
354
+ },
355
+ [track]
356
+ );
357
+ const trackFailure = useCallback(() => {
358
+ if (!mounted.current || responseStarted.current === null) return;
359
+ const durationMs = Math.max(0, Date.now() - responseStarted.current);
360
+ responseStarted.current = null;
361
+ track({ type: "response_failed", durationMs });
362
+ }, [track]);
331
363
  const {
332
364
  messages,
333
365
  sendMessage,
@@ -341,15 +373,16 @@ function ChatInterface({
341
373
  id: `${chatInstanceId}:${isPublicMode ? "public" : identitySessionVersion}`,
342
374
  transport,
343
375
  // Automatically resubmit after all client-side tool results are provided
344
- sendAutomaticallyWhen: (options) => (isPublicMode || identitySessionVersion === identitySessionGeneration.current) && lastAssistantMessageIsCompleteWithToolCalls(options),
376
+ sendAutomaticallyWhen: (options) => mounted.current && (isPublicMode || identitySessionVersion === identitySessionGeneration.current) && lastAssistantMessageIsCompleteWithToolCalls(options),
345
377
  onToolCall: async ({ toolCall }) => {
378
+ if (!mounted.current) return;
346
379
  const toolRequest = pendingStreamRequests.current.at(-1);
347
380
  if (!isPublicMode && (identitySessionVersion !== identitySessionGeneration.current || !toolRequest || toolRequest.identityPartitionKey !== latestIdentityPartitionKey.current || toolRequest.generation !== latestStreamRequestGeneration.current || activeStreamPartitionKey.current !== toolRequest.identityPartitionKey)) {
348
381
  return;
349
382
  }
350
383
  const toolIdentityPartitionKey = toolRequest?.identityPartitionKey ?? latestIdentityPartitionKey.current;
351
384
  const toolStreamRequestGeneration = toolRequest?.generation ?? latestStreamRequestGeneration.current;
352
- const toolRequestIsCurrent = () => isPublicMode || identitySessionVersion === identitySessionGeneration.current && toolIdentityPartitionKey === latestIdentityPartitionKey.current && toolStreamRequestGeneration === latestStreamRequestGeneration.current;
385
+ const toolRequestIsCurrent = () => mounted.current && (isPublicMode || identitySessionVersion === identitySessionGeneration.current && toolIdentityPartitionKey === latestIdentityPartitionKey.current && toolStreamRequestGeneration === latestStreamRequestGeneration.current);
353
386
  const toolName = toolCall.toolName;
354
387
  const handler = pageAIContextRef.current?.clientTools?.[toolName];
355
388
  if (handler) {
@@ -389,6 +422,7 @@ function ChatInterface({
389
422
  }
390
423
  },
391
424
  onError: (err) => {
425
+ if (!mounted.current) return;
392
426
  console.error("useChat onError:", err);
393
427
  if (!isPublicMode && identitySessionVersion !== identitySessionGeneration.current) {
394
428
  return;
@@ -398,8 +432,30 @@ function ChatInterface({
398
432
  if (!isPublicMode && failedPartitionKey !== latestIdentityPartitionKey.current) {
399
433
  return;
400
434
  }
435
+ trackFailure();
401
436
  },
402
- onFinish: async () => {
437
+ onFinish: async (completion) => {
438
+ if (!mounted.current || !isPublicMode && identitySessionVersion !== identitySessionGeneration.current)
439
+ return;
440
+ if (completion?.isError || completion?.isDisconnect) trackFailure();
441
+ else if (completion?.isAbort) responseStarted.current = null;
442
+ else if (completion && responseStarted.current !== null && !lastAssistantMessageIsCompleteWithToolCalls({
443
+ messages: completion.messages
444
+ })) {
445
+ const durationMs = Math.max(0, Date.now() - responseStarted.current);
446
+ responseStarted.current = null;
447
+ track({
448
+ type: "response_completed",
449
+ durationMs,
450
+ outputLength: completion.message.parts.reduce(
451
+ (count, part) => count + (part.type === "text" ? part.text.length : 0),
452
+ 0
453
+ ),
454
+ toolCallCount: completion.message.parts.filter(
455
+ (part) => part.type === "dynamic-tool" || part.type.startsWith("tool-")
456
+ ).length
457
+ });
458
+ }
403
459
  if (isPublicMode) {
404
460
  activeStreamPartitionKey.current = void 0;
405
461
  return;
@@ -415,7 +471,7 @@ function ChatInterface({
415
471
  if (!finishedRequest || finishedRequest.generation === latestStreamRequestGeneration.current) {
416
472
  activeStreamPartitionKey.current = void 0;
417
473
  }
418
- const identityIsCurrent = () => finishingPartitionKey === latestIdentityPartitionKey.current && (!finishedRequest || finishedRequest.generation === latestStreamRequestGeneration.current);
474
+ const identityIsCurrent = () => mounted.current && finishingPartitionKey === latestIdentityPartitionKey.current && (!finishedRequest || finishedRequest.generation === latestStreamRequestGeneration.current);
419
475
  const finishingIdentityPartition = latestIdentityPartition.current;
420
476
  const finishingConversationsListQueryKey = latestConversationsListQueryKey.current;
421
477
  const finishingHeaders = latestHeaders.current;
@@ -596,6 +652,15 @@ function ChatInterface({
596
652
  useLayoutEffect(() => {
597
653
  latestMessages.current = messages;
598
654
  }, [messages]);
655
+ const stopRef = useRef(stop);
656
+ stopRef.current = stop;
657
+ useEffect(() => {
658
+ mounted.current = true;
659
+ return () => {
660
+ mounted.current = false;
661
+ void stopRef.current();
662
+ };
663
+ }, []);
599
664
  const previousIdentityPartition = useRef(identityPartitionKey);
600
665
  useLayoutEffect(() => {
601
666
  const nextPartition = identityPartitionKey;
@@ -609,6 +674,7 @@ function ChatInterface({
609
674
  activeStreamPartitionKey.current = void 0;
610
675
  pendingStreamRequests.current = [];
611
676
  identitySessionGeneration.current += 1;
677
+ responseStarted.current = null;
612
678
  setIdentitySessionVersion(identitySessionGeneration.current);
613
679
  latestStreamRequestGeneration.current = ++nextStreamRequestGeneration.current;
614
680
  void stop();
@@ -691,12 +757,20 @@ function ChatInterface({
691
757
  }
692
758
  }, [messages, isPublicMode, onMessagesChange, isMessagesInitialized]);
693
759
  const handleInputChange = (e) => {
760
+ inputKind.current = "typed";
694
761
  setInput(e.target.value);
695
762
  };
696
763
  const handleSubmit = async (e, files) => {
697
764
  e.preventDefault();
698
765
  const text = input.trim();
699
766
  if (!text && (!files || files.length === 0)) return;
767
+ trackSubmission(
768
+ inputKind.current,
769
+ text.length,
770
+ messages.length + 1,
771
+ files?.length ?? 0
772
+ );
773
+ inputKind.current = "typed";
700
774
  if (!isPublicMode && !id && messages.length === 0) {
701
775
  isFirstMessageSentRef.current = true;
702
776
  }
@@ -735,6 +809,7 @@ function ChatInterface({
735
809
  if (!isPublicMode && (sendIdentityPartitionKey !== latestIdentityPartitionKey.current || sendIdentitySessionGeneration !== identitySessionGeneration.current)) {
736
810
  return;
737
811
  }
812
+ trackFailure();
738
813
  setInput(savedInput);
739
814
  setAttachedFiles(savedFiles);
740
815
  if (isFirstMessageSentRef.current && !hasNavigatedRef.current) {
@@ -748,17 +823,19 @@ function ChatInterface({
748
823
  const handleRetry = useCallback(() => {
749
824
  setHistorySyncError(null);
750
825
  activeStreamPartitionKey.current = latestIdentityPartitionKey.current;
826
+ trackSubmission("retry", 0, messages.length);
751
827
  regenerate();
752
- }, [regenerate]);
828
+ }, [regenerate, trackSubmission, messages.length]);
753
829
  useEffect(() => {
754
830
  if (pendingEdit && pendingEdit.identityPartitionKey === latestIdentityPartitionKey.current && pendingEdit.streamRequestGeneration === latestStreamRequestGeneration.current && messages.length === pendingEdit.expectedLength) {
755
831
  const textToSend = pendingEdit.text;
756
832
  setPendingEdit(null);
757
833
  isEditInProgressRef.current = false;
758
834
  activeStreamPartitionKey.current = latestIdentityPartitionKey.current;
835
+ trackSubmission("edit", textToSend.length, messages.length + 1);
759
836
  sendMessage({ text: textToSend });
760
837
  }
761
- }, [messages.length, pendingEdit, sendMessage]);
838
+ }, [messages.length, pendingEdit, sendMessage, trackSubmission]);
762
839
  const handleEditMessage = useCallback(
763
840
  (messageId, newText) => {
764
841
  setHistorySyncError(null);
@@ -817,7 +894,11 @@ function ChatInterface({
817
894
  "button",
818
895
  {
819
896
  type: "button",
820
- onClick: () => setInput(suggestion),
897
+ onClick: () => {
898
+ inputKind.current = "suggestion";
899
+ track({ type: "suggestion_selected", index });
900
+ setInput(suggestion);
901
+ },
821
902
  className: "px-3 py-2 text-sm rounded-lg border border-border bg-background hover:bg-accent hover:text-accent-foreground transition-colors text-foreground",
822
903
  children: suggestion
823
904
  },
@@ -828,6 +909,8 @@ function ChatInterface({
828
909
  PermissionedChatMessage,
829
910
  {
830
911
  publicMode: isPublicMode,
912
+ toolNames: effectiveToolNames,
913
+ routeName: pageAIContext?.routeName,
831
914
  action: m.role === "user" ? "edit" : m.role === "assistant" && index === messages.length - 1 ? "retry" : void 0,
832
915
  conversationId: currentConversationId,
833
916
  ownerId: currentConversationOwnerId,
@@ -878,7 +961,7 @@ function ChatInterface({
878
961
  action: "send",
879
962
  conversationId: currentConversationId,
880
963
  ownerId: currentConversationOwnerId,
881
- toolNames: Object.keys(pageAIContext?.clientTools ?? {}),
964
+ toolNames: effectiveToolNames,
882
965
  routeName: pageAIContext?.routeName,
883
966
  children: (allowed) => allowed && !historySyncError ? /* @__PURE__ */ jsx(
884
967
  "div",
@@ -13,13 +13,19 @@ const chatInterface = require('./chat-interface.cjs');
13
13
  const plugins_aiChat_client_context_pageAiContext = require('../../../../../../../plugins/ai-chat/client/context/page-ai-context.cjs');
14
14
  const context = require('@btst/stack/context');
15
15
  const overrides = require('../overrides.cjs');
16
+ const chatAnalytics = require('../context/chat-analytics.cjs');
16
17
  const index = require('../localization/index.cjs');
17
18
 
18
19
  function ChatLayout(props) {
20
+ return /* @__PURE__ */ jsxRuntime.jsx(plugins_aiChat_client_context_pageAiContext.PageAIContextScope.Provider, { value: props.pageContext, children: /* @__PURE__ */ jsxRuntime.jsx(chatAnalytics.ChatAnalyticsPageKey.Provider, { value: props.pageKey, children: /* @__PURE__ */ jsxRuntime.jsx(ChatLayoutContent, { ...props }, props.pageKey) }) });
21
+ }
22
+ function ChatLayoutContent(props) {
23
+ const track = chatAnalytics.useChatAnalytics();
19
24
  const {
20
25
  conversationId,
21
26
  layout = "full",
22
27
  className,
28
+ style,
23
29
  showSidebar: requestedShowSidebar = true,
24
30
  initialMessages,
25
31
  onMessagesChange,
@@ -30,8 +36,8 @@ function ChatLayout(props) {
30
36
  const resolvedMode = overrides.resolveAiChatMode(plugins?.aiChat?.config);
31
37
  const showSidebar = requestedShowSidebar && resolvedMode !== "public";
32
38
  const tr = index.useAiChatTranslation(localization);
33
- const widgetHeight = props.layout === "widget" ? props.widgetHeight ?? "600px" : "600px";
34
- const widgetWidth = props.layout === "widget" ? props.widgetWidth ?? "380px" : "380px";
39
+ const widgetHeight = props.layout === "widget" ? props.widgetHeight ?? "min(600px, calc(100dvh - 112px))" : void 0;
40
+ const widgetWidth = props.layout === "widget" ? props.widgetWidth ?? "min(380px, calc(100vw - 32px))" : void 0;
35
41
  const defaultOpen = props.layout === "widget" ? props.defaultOpen ?? false : false;
36
42
  const showTrigger = props.layout === "widget" ? props.showTrigger ?? true : true;
37
43
  const [sidebarOpen, setSidebarOpen] = React.useState(true);
@@ -40,6 +46,37 @@ function ChatLayout(props) {
40
46
  const [widgetOpen, setWidgetOpen] = React.useState(defaultOpen);
41
47
  const [widgetResetKey, setWidgetResetKey] = React.useState(0);
42
48
  const [widgetEverOpened, setWidgetEverOpened] = React.useState(defaultOpen);
49
+ const [tipDismissed, setTipDismissed] = React.useState(defaultOpen);
50
+ const introTip = props.layout === "widget" ? props.introTip : void 0;
51
+ const introTipDuration = props.layout === "widget" ? props.introTipDuration ?? 6e3 : 6e3;
52
+ const tipShown = React.useRef(false);
53
+ const tipDismissedRef = React.useRef(defaultOpen);
54
+ const dismissTip = React.useCallback(
55
+ (reason) => {
56
+ if (tipShown.current && !tipDismissedRef.current) {
57
+ tipDismissedRef.current = true;
58
+ track({ type: "intro_tip_dismissed", reason });
59
+ }
60
+ setTipDismissed(true);
61
+ },
62
+ [track]
63
+ );
64
+ React.useEffect(() => {
65
+ if (!introTip || !showTrigger || tipDismissed) return;
66
+ if (!tipShown.current) {
67
+ tipShown.current = true;
68
+ track({ type: "intro_tip_shown" });
69
+ }
70
+ const timeout = setTimeout(() => dismissTip("timeout"), introTipDuration);
71
+ return () => clearTimeout(timeout);
72
+ }, [
73
+ introTip,
74
+ introTipDuration,
75
+ showTrigger,
76
+ tipDismissed,
77
+ dismissTip,
78
+ track
79
+ ]);
43
80
  const pageAIContext = plugins_aiChat_client_context_pageAiContext.usePageAIContext();
44
81
  const handleNewChat = React.useCallback(() => {
45
82
  if (!conversationId) {
@@ -52,10 +89,38 @@ function ChatLayout(props) {
52
89
  {
53
90
  className: utils.cn(
54
91
  "flex flex-col items-end gap-3",
92
+ props.layout === "widget" && props.floating && "fixed right-4 bottom-[max(1rem,env(safe-area-inset-bottom))] z-50",
55
93
  className,
56
94
  classNames?.container
57
95
  ),
96
+ style,
58
97
  children: [
98
+ introTip && showTrigger && !tipDismissed && !widgetOpen && /* @__PURE__ */ jsxRuntime.jsxs(
99
+ "div",
100
+ {
101
+ role: "status",
102
+ className: "flex max-w-[min(280px,calc(100vw-32px))] items-center gap-2 rounded-lg border bg-background p-3 text-sm text-foreground shadow-lg",
103
+ children: [
104
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: introTip }),
105
+ /* @__PURE__ */ jsxRuntime.jsx(
106
+ button.Button,
107
+ {
108
+ type: "button",
109
+ variant: "ghost",
110
+ size: "icon",
111
+ className: "h-7 w-7 shrink-0",
112
+ onClick: () => dismissTip("manual"),
113
+ "aria-label": tr(
114
+ "A11Y_DISMISS_CHAT_TIP",
115
+ "aiChat.a11y.dismissChatTip",
116
+ "Dismiss chat tip"
117
+ ),
118
+ children: /* @__PURE__ */ jsxRuntime.jsx(LucideIcons.X, { className: "h-4 w-4", "aria-hidden": "true" })
119
+ }
120
+ )
121
+ ]
122
+ }
123
+ ),
59
124
  /* @__PURE__ */ jsxRuntime.jsxs(
60
125
  "div",
61
126
  {
@@ -84,6 +149,7 @@ function ChatLayout(props) {
84
149
  size: "icon",
85
150
  className: "h-5 w-5",
86
151
  onClick: () => {
152
+ track({ type: "conversation_cleared" });
87
153
  onClear?.();
88
154
  setWidgetResetKey((prev) => prev + 1);
89
155
  },
@@ -106,7 +172,10 @@ function ChatLayout(props) {
106
172
  variant: "ghost",
107
173
  size: "icon",
108
174
  className: "h-5 w-5",
109
- onClick: () => setWidgetOpen(false),
175
+ onClick: () => {
176
+ setWidgetOpen(false);
177
+ track({ type: "widget_closed" });
178
+ },
110
179
  "aria-label": tr(
111
180
  "A11Y_CLOSE_CHAT",
112
181
  "aiChat.a11y.closeChat",
@@ -135,6 +204,8 @@ function ChatLayout(props) {
135
204
  size: "icon",
136
205
  className: "h-12 w-12 rounded-full shadow-lg",
137
206
  onClick: () => {
207
+ dismissTip("open");
208
+ track({ type: widgetOpen ? "widget_closed" : "widget_opened" });
138
209
  setWidgetOpen((prev) => !prev);
139
210
  setWidgetEverOpened(true);
140
211
  },
@@ -156,6 +227,7 @@ function ChatLayout(props) {
156
227
  classNames?.container
157
228
  ),
158
229
  "data-testid": "chat-layout",
230
+ style,
159
231
  children: [
160
232
  showSidebar && /* @__PURE__ */ jsxRuntime.jsx(
161
233
  "div",
@@ -240,6 +312,7 @@ function ChatLayout(props) {
240
312
  variant: "ghost",
241
313
  size: "icon",
242
314
  onClick: () => {
315
+ track({ type: "conversation_cleared" });
243
316
  onClear();
244
317
  setChatResetKey((prev) => prev + 1);
245
318
  },
@@ -1,23 +1,29 @@
1
1
  "use client";
2
- import { jsxs, jsx } from 'react/jsx-runtime';
3
- import { useState, useCallback } from 'react';
2
+ import { jsx, jsxs } from 'react/jsx-runtime';
3
+ import { useState, useRef, useCallback, useEffect } from 'react';
4
4
  import { Button } from '../../../../../../ui/src/components/button.mjs';
5
5
  import { Badge } from '../../../../../../ui/src/components/badge.mjs';
6
6
  import { Sheet, SheetTrigger, SheetContent } from '../../../../../../ui/src/components/sheet.mjs';
7
- import { Sparkles, Trash2, X, Menu, PanelLeftClose, PanelLeft } from 'lucide-react';
7
+ import { X, Sparkles, Trash2, Menu, PanelLeftClose, PanelLeft } from 'lucide-react';
8
8
  import { cn } from '../../../../../../ui/src/lib/utils.mjs';
9
9
  import { ChatSidebar } from './chat-sidebar.mjs';
10
10
  import { ChatInterface } from './chat-interface.mjs';
11
- import { usePageAIContext } from '../../../../../../../plugins/ai-chat/client/context/page-ai-context.mjs';
11
+ import { PageAIContextScope, usePageAIContext } from '../../../../../../../plugins/ai-chat/client/context/page-ai-context.mjs';
12
12
  import { usePluginOverrides, useStack } from '@btst/stack/context';
13
13
  import { resolveAiChatMode } from '../overrides.mjs';
14
+ import { ChatAnalyticsPageKey, useChatAnalytics } from '../context/chat-analytics.mjs';
14
15
  import { useAiChatTranslation } from '../localization/index.mjs';
15
16
 
16
17
  function ChatLayout(props) {
18
+ return /* @__PURE__ */ jsx(PageAIContextScope.Provider, { value: props.pageContext, children: /* @__PURE__ */ jsx(ChatAnalyticsPageKey.Provider, { value: props.pageKey, children: /* @__PURE__ */ jsx(ChatLayoutContent, { ...props }, props.pageKey) }) });
19
+ }
20
+ function ChatLayoutContent(props) {
21
+ const track = useChatAnalytics();
17
22
  const {
18
23
  conversationId,
19
24
  layout = "full",
20
25
  className,
26
+ style,
21
27
  showSidebar: requestedShowSidebar = true,
22
28
  initialMessages,
23
29
  onMessagesChange,
@@ -28,8 +34,8 @@ function ChatLayout(props) {
28
34
  const resolvedMode = resolveAiChatMode(plugins?.aiChat?.config);
29
35
  const showSidebar = requestedShowSidebar && resolvedMode !== "public";
30
36
  const tr = useAiChatTranslation(localization);
31
- const widgetHeight = props.layout === "widget" ? props.widgetHeight ?? "600px" : "600px";
32
- const widgetWidth = props.layout === "widget" ? props.widgetWidth ?? "380px" : "380px";
37
+ const widgetHeight = props.layout === "widget" ? props.widgetHeight ?? "min(600px, calc(100dvh - 112px))" : void 0;
38
+ const widgetWidth = props.layout === "widget" ? props.widgetWidth ?? "min(380px, calc(100vw - 32px))" : void 0;
33
39
  const defaultOpen = props.layout === "widget" ? props.defaultOpen ?? false : false;
34
40
  const showTrigger = props.layout === "widget" ? props.showTrigger ?? true : true;
35
41
  const [sidebarOpen, setSidebarOpen] = useState(true);
@@ -38,6 +44,37 @@ function ChatLayout(props) {
38
44
  const [widgetOpen, setWidgetOpen] = useState(defaultOpen);
39
45
  const [widgetResetKey, setWidgetResetKey] = useState(0);
40
46
  const [widgetEverOpened, setWidgetEverOpened] = useState(defaultOpen);
47
+ const [tipDismissed, setTipDismissed] = useState(defaultOpen);
48
+ const introTip = props.layout === "widget" ? props.introTip : void 0;
49
+ const introTipDuration = props.layout === "widget" ? props.introTipDuration ?? 6e3 : 6e3;
50
+ const tipShown = useRef(false);
51
+ const tipDismissedRef = useRef(defaultOpen);
52
+ const dismissTip = useCallback(
53
+ (reason) => {
54
+ if (tipShown.current && !tipDismissedRef.current) {
55
+ tipDismissedRef.current = true;
56
+ track({ type: "intro_tip_dismissed", reason });
57
+ }
58
+ setTipDismissed(true);
59
+ },
60
+ [track]
61
+ );
62
+ useEffect(() => {
63
+ if (!introTip || !showTrigger || tipDismissed) return;
64
+ if (!tipShown.current) {
65
+ tipShown.current = true;
66
+ track({ type: "intro_tip_shown" });
67
+ }
68
+ const timeout = setTimeout(() => dismissTip("timeout"), introTipDuration);
69
+ return () => clearTimeout(timeout);
70
+ }, [
71
+ introTip,
72
+ introTipDuration,
73
+ showTrigger,
74
+ tipDismissed,
75
+ dismissTip,
76
+ track
77
+ ]);
41
78
  const pageAIContext = usePageAIContext();
42
79
  const handleNewChat = useCallback(() => {
43
80
  if (!conversationId) {
@@ -50,10 +87,38 @@ function ChatLayout(props) {
50
87
  {
51
88
  className: cn(
52
89
  "flex flex-col items-end gap-3",
90
+ props.layout === "widget" && props.floating && "fixed right-4 bottom-[max(1rem,env(safe-area-inset-bottom))] z-50",
53
91
  className,
54
92
  classNames?.container
55
93
  ),
94
+ style,
56
95
  children: [
96
+ introTip && showTrigger && !tipDismissed && !widgetOpen && /* @__PURE__ */ jsxs(
97
+ "div",
98
+ {
99
+ role: "status",
100
+ className: "flex max-w-[min(280px,calc(100vw-32px))] items-center gap-2 rounded-lg border bg-background p-3 text-sm text-foreground shadow-lg",
101
+ children: [
102
+ /* @__PURE__ */ jsx("span", { children: introTip }),
103
+ /* @__PURE__ */ jsx(
104
+ Button,
105
+ {
106
+ type: "button",
107
+ variant: "ghost",
108
+ size: "icon",
109
+ className: "h-7 w-7 shrink-0",
110
+ onClick: () => dismissTip("manual"),
111
+ "aria-label": tr(
112
+ "A11Y_DISMISS_CHAT_TIP",
113
+ "aiChat.a11y.dismissChatTip",
114
+ "Dismiss chat tip"
115
+ ),
116
+ children: /* @__PURE__ */ jsx(X, { className: "h-4 w-4", "aria-hidden": "true" })
117
+ }
118
+ )
119
+ ]
120
+ }
121
+ ),
57
122
  /* @__PURE__ */ jsxs(
58
123
  "div",
59
124
  {
@@ -82,6 +147,7 @@ function ChatLayout(props) {
82
147
  size: "icon",
83
148
  className: "h-5 w-5",
84
149
  onClick: () => {
150
+ track({ type: "conversation_cleared" });
85
151
  onClear?.();
86
152
  setWidgetResetKey((prev) => prev + 1);
87
153
  },
@@ -104,7 +170,10 @@ function ChatLayout(props) {
104
170
  variant: "ghost",
105
171
  size: "icon",
106
172
  className: "h-5 w-5",
107
- onClick: () => setWidgetOpen(false),
173
+ onClick: () => {
174
+ setWidgetOpen(false);
175
+ track({ type: "widget_closed" });
176
+ },
108
177
  "aria-label": tr(
109
178
  "A11Y_CLOSE_CHAT",
110
179
  "aiChat.a11y.closeChat",
@@ -133,6 +202,8 @@ function ChatLayout(props) {
133
202
  size: "icon",
134
203
  className: "h-12 w-12 rounded-full shadow-lg",
135
204
  onClick: () => {
205
+ dismissTip("open");
206
+ track({ type: widgetOpen ? "widget_closed" : "widget_opened" });
136
207
  setWidgetOpen((prev) => !prev);
137
208
  setWidgetEverOpened(true);
138
209
  },
@@ -154,6 +225,7 @@ function ChatLayout(props) {
154
225
  classNames?.container
155
226
  ),
156
227
  "data-testid": "chat-layout",
228
+ style,
157
229
  children: [
158
230
  showSidebar && /* @__PURE__ */ jsx(
159
231
  "div",
@@ -238,6 +310,7 @@ function ChatLayout(props) {
238
310
  variant: "ghost",
239
311
  size: "icon",
240
312
  onClick: () => {
313
+ track({ type: "conversation_cleared" });
241
314
  onClear();
242
315
  setChatResetKey((prev) => prev + 1);
243
316
  },
@@ -54,6 +54,23 @@ function ToolCallDisplay({
54
54
  }) {
55
55
  const { localization } = context.usePluginOverrides("aiChat", {});
56
56
  const tr = index.useAiChatTranslation(localization);
57
+ const { plugins } = context.useStack();
58
+ if (toolName === "readPage" && plugins?.aiChat?.config?.pageContent === true) {
59
+ const failed = state === "output-error";
60
+ return /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-muted-foreground", role: "status", children: failed ? tr(
61
+ "PAGE_CONTENT_ERROR",
62
+ "aiChat.pageContent.error",
63
+ "Unable to read this page."
64
+ ) : state === "output-available" ? tr(
65
+ "PAGE_CONTENT_READ",
66
+ "aiChat.pageContent.read",
67
+ "Read page content"
68
+ ) : tr(
69
+ "PAGE_CONTENT_READING",
70
+ "aiChat.pageContent.reading",
71
+ "Reading page content\u2026"
72
+ ) });
73
+ }
57
74
  const displayName = formatToolName(toolName);
58
75
  const statusLabel = isLoading || state === "input-streaming" ? tr("TOOL_STATUS_RUNNING", "aiChat.tools.status.running", "Running...") : state === "input-available" ? tr(
59
76
  "TOOL_STATUS_EXECUTING",
@@ -5,7 +5,7 @@ import { MarkdownContent } from '../../../../../../ui/src/components/markdown-co
5
5
  import { Skeleton } from '../../../../../../ui/src/components/skeleton.mjs';
6
6
  import { cn } from '../../../../../../ui/src/lib/utils.mjs';
7
7
  import { Wrench, Loader2, AlertCircle, Check } from 'lucide-react';
8
- import { usePluginOverrides } from '@btst/stack/context';
8
+ import { usePluginOverrides, useStack } from '@btst/stack/context';
9
9
  import { useAiChatTranslation } from '../localization/index.mjs';
10
10
 
11
11
  function formatToolName(name) {
@@ -52,6 +52,23 @@ function ToolCallDisplay({
52
52
  }) {
53
53
  const { localization } = usePluginOverrides("aiChat", {});
54
54
  const tr = useAiChatTranslation(localization);
55
+ const { plugins } = useStack();
56
+ if (toolName === "readPage" && plugins?.aiChat?.config?.pageContent === true) {
57
+ const failed = state === "output-error";
58
+ return /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", role: "status", children: failed ? tr(
59
+ "PAGE_CONTENT_ERROR",
60
+ "aiChat.pageContent.error",
61
+ "Unable to read this page."
62
+ ) : state === "output-available" ? tr(
63
+ "PAGE_CONTENT_READ",
64
+ "aiChat.pageContent.read",
65
+ "Read page content"
66
+ ) : tr(
67
+ "PAGE_CONTENT_READING",
68
+ "aiChat.pageContent.reading",
69
+ "Reading page content\u2026"
70
+ ) });
71
+ }
55
72
  const displayName = formatToolName(toolName);
56
73
  const statusLabel = isLoading || state === "input-streaming" ? tr("TOOL_STATUS_RUNNING", "aiChat.tools.status.running", "Running...") : state === "input-available" ? tr(
57
74
  "TOOL_STATUS_EXECUTING",
@@ -0,0 +1,42 @@
1
+ "use client";
2
+ 'use strict';
3
+
4
+ const React = require('react');
5
+ const context = require('@btst/stack/context');
6
+ const plugins_aiChat_client_context_pageAiContext = require('../../../../../../../plugins/ai-chat/client/context/page-ai-context.cjs');
7
+ const overrides = require('../overrides.cjs');
8
+
9
+ const ChatAnalyticsPageKey = React.createContext(
10
+ void 0
11
+ );
12
+ function useChatAnalytics() {
13
+ const { onAnalyticsEvent } = context.usePluginOverrides("aiChat", {});
14
+ const { plugins } = context.useStack();
15
+ const pageKey = React.useContext(ChatAnalyticsPageKey);
16
+ const routeName = plugins_aiChat_client_context_pageAiContext.usePageAIContext()?.routeName;
17
+ const latest = React.useRef({
18
+ onAnalyticsEvent,
19
+ pageKey,
20
+ routeName,
21
+ mode: overrides.resolveAiChatMode(plugins?.aiChat?.config)
22
+ });
23
+ latest.current = {
24
+ onAnalyticsEvent,
25
+ pageKey,
26
+ routeName,
27
+ mode: overrides.resolveAiChatMode(plugins?.aiChat?.config)
28
+ };
29
+ return React.useCallback((event) => {
30
+ const { onAnalyticsEvent: callback, ...context } = latest.current;
31
+ try {
32
+ void Promise.resolve(callback?.({ ...context, ...event })).catch(
33
+ () => {
34
+ }
35
+ );
36
+ } catch {
37
+ }
38
+ }, []);
39
+ }
40
+
41
+ exports.ChatAnalyticsPageKey = ChatAnalyticsPageKey;
42
+ exports.useChatAnalytics = useChatAnalytics;