@btst/stack 2.2.0 → 2.4.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 (237) hide show
  1. package/dist/packages/stack/src/client/components/compose.cjs +1 -2
  2. package/dist/packages/stack/src/client/components/compose.mjs +1 -2
  3. package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.cjs +71 -0
  4. package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.mjs +68 -0
  5. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.cjs +54 -7
  6. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.mjs +54 -7
  7. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.cjs +2 -2
  8. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.mjs +2 -2
  9. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.cjs +89 -22
  10. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +90 -23
  11. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.cjs +110 -33
  12. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.mjs +112 -35
  13. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.cjs +1 -1
  14. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.mjs +1 -1
  15. package/dist/packages/stack/src/plugins/ai-chat/schemas.cjs +17 -1
  16. package/dist/packages/stack/src/plugins/ai-chat/schemas.mjs +17 -1
  17. package/dist/packages/stack/src/plugins/blog/api/plugin.cjs +52 -1
  18. package/dist/packages/stack/src/plugins/blog/api/plugin.mjs +52 -1
  19. package/dist/packages/stack/src/plugins/blog/api/query-key-defs.cjs +18 -0
  20. package/dist/packages/stack/src/plugins/blog/api/query-key-defs.mjs +15 -0
  21. package/dist/packages/stack/src/plugins/blog/api/serializers.cjs +21 -0
  22. package/dist/packages/stack/src/plugins/blog/api/serializers.mjs +18 -0
  23. package/dist/packages/stack/src/plugins/blog/client/components/forms/post-forms.cjs +15 -2
  24. package/dist/packages/stack/src/plugins/blog/client/components/forms/post-forms.mjs +16 -3
  25. package/dist/packages/stack/src/plugins/blog/client/components/pages/edit-post-page.internal.cjs +24 -1
  26. package/dist/packages/stack/src/plugins/blog/client/components/pages/edit-post-page.internal.mjs +24 -1
  27. package/dist/packages/stack/src/plugins/blog/client/components/pages/fill-blog-form-handler.cjs +26 -0
  28. package/dist/packages/stack/src/plugins/blog/client/components/pages/fill-blog-form-handler.mjs +24 -0
  29. package/dist/packages/stack/src/plugins/blog/client/components/pages/new-post-page.internal.cjs +30 -1
  30. package/dist/packages/stack/src/plugins/blog/client/components/pages/new-post-page.internal.mjs +30 -1
  31. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.cjs +18 -0
  32. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.mjs +18 -0
  33. package/dist/packages/stack/src/plugins/blog/client/plugin.cjs +15 -0
  34. package/dist/packages/stack/src/plugins/blog/client/plugin.mjs +16 -1
  35. package/dist/packages/stack/src/plugins/cms/api/getters.cjs +10 -0
  36. package/dist/packages/stack/src/plugins/cms/api/getters.mjs +10 -1
  37. package/dist/packages/stack/src/plugins/cms/api/mutations.cjs +48 -0
  38. package/dist/packages/stack/src/plugins/cms/api/mutations.mjs +46 -0
  39. package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +75 -0
  40. package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +76 -1
  41. package/dist/packages/stack/src/plugins/cms/api/query-key-defs.cjs +29 -0
  42. package/dist/packages/stack/src/plugins/cms/api/query-key-defs.mjs +26 -0
  43. package/dist/packages/stack/src/plugins/cms/client/plugin.cjs +15 -0
  44. package/dist/packages/stack/src/plugins/cms/client/plugin.mjs +16 -1
  45. package/dist/packages/stack/src/plugins/form-builder/api/getters.cjs +9 -0
  46. package/dist/packages/stack/src/plugins/form-builder/api/getters.mjs +9 -1
  47. package/dist/packages/stack/src/plugins/form-builder/api/plugin.cjs +62 -1
  48. package/dist/packages/stack/src/plugins/form-builder/api/plugin.mjs +63 -2
  49. package/dist/packages/stack/src/plugins/form-builder/api/query-key-defs.cjs +37 -0
  50. package/dist/packages/stack/src/plugins/form-builder/api/query-key-defs.mjs +33 -0
  51. package/dist/packages/stack/src/plugins/form-builder/client/plugin.cjs +15 -0
  52. package/dist/packages/stack/src/plugins/form-builder/client/plugin.mjs +16 -1
  53. package/dist/packages/stack/src/plugins/kanban/api/mutations.cjs +91 -0
  54. package/dist/packages/stack/src/plugins/kanban/api/mutations.mjs +87 -0
  55. package/dist/packages/stack/src/plugins/kanban/api/plugin.cjs +34 -1
  56. package/dist/packages/stack/src/plugins/kanban/api/plugin.mjs +34 -1
  57. package/dist/packages/stack/src/plugins/kanban/api/query-key-defs.cjs +26 -0
  58. package/dist/packages/stack/src/plugins/kanban/api/query-key-defs.mjs +23 -0
  59. package/dist/packages/stack/src/plugins/kanban/api/serializers.cjs +30 -0
  60. package/dist/packages/stack/src/plugins/kanban/api/serializers.mjs +26 -0
  61. package/dist/packages/stack/src/plugins/kanban/client/hooks/kanban-hooks.cjs +7 -3
  62. package/dist/packages/stack/src/plugins/kanban/client/hooks/kanban-hooks.mjs +7 -3
  63. package/dist/packages/stack/src/plugins/kanban/client/plugin.cjs +10 -0
  64. package/dist/packages/stack/src/plugins/kanban/client/plugin.mjs +11 -1
  65. package/dist/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.cjs +89 -0
  66. package/dist/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.mjs +89 -0
  67. package/dist/packages/stack/src/plugins/utils.cjs +6 -0
  68. package/dist/packages/stack/src/plugins/utils.mjs +6 -1
  69. package/dist/plugins/ai-chat/api/index.d.cts +1 -1
  70. package/dist/plugins/ai-chat/api/index.d.mts +1 -1
  71. package/dist/plugins/ai-chat/api/index.d.ts +1 -1
  72. package/dist/plugins/ai-chat/client/components/index.d.cts +1 -1
  73. package/dist/plugins/ai-chat/client/components/index.d.mts +1 -1
  74. package/dist/plugins/ai-chat/client/components/index.d.ts +1 -1
  75. package/dist/plugins/ai-chat/client/context/page-ai-context.cjs +92 -0
  76. package/dist/plugins/ai-chat/client/context/page-ai-context.d.cts +84 -0
  77. package/dist/plugins/ai-chat/client/context/page-ai-context.d.mts +84 -0
  78. package/dist/plugins/ai-chat/client/context/page-ai-context.d.ts +84 -0
  79. package/dist/plugins/ai-chat/client/context/page-ai-context.mjs +88 -0
  80. package/dist/plugins/ai-chat/client/hooks/index.d.cts +1 -1
  81. package/dist/plugins/ai-chat/client/hooks/index.d.mts +1 -1
  82. package/dist/plugins/ai-chat/client/hooks/index.d.ts +1 -1
  83. package/dist/plugins/ai-chat/client/index.d.cts +2 -2
  84. package/dist/plugins/ai-chat/client/index.d.mts +2 -2
  85. package/dist/plugins/ai-chat/client/index.d.ts +2 -2
  86. package/dist/plugins/ai-chat/query-keys.d.cts +1 -1
  87. package/dist/plugins/ai-chat/query-keys.d.mts +1 -1
  88. package/dist/plugins/ai-chat/query-keys.d.ts +1 -1
  89. package/dist/plugins/blog/api/index.cjs +5 -0
  90. package/dist/plugins/blog/api/index.d.cts +19 -4
  91. package/dist/plugins/blog/api/index.d.mts +19 -4
  92. package/dist/plugins/blog/api/index.d.ts +19 -4
  93. package/dist/plugins/blog/api/index.mjs +2 -0
  94. package/dist/plugins/blog/client/hooks/index.d.cts +3 -3
  95. package/dist/plugins/blog/client/hooks/index.d.mts +3 -3
  96. package/dist/plugins/blog/client/hooks/index.d.ts +3 -3
  97. package/dist/plugins/blog/client/index.d.cts +1 -1
  98. package/dist/plugins/blog/client/index.d.mts +1 -1
  99. package/dist/plugins/blog/client/index.d.ts +1 -1
  100. package/dist/plugins/blog/query-keys.cjs +6 -5
  101. package/dist/plugins/blog/query-keys.d.cts +8 -387
  102. package/dist/plugins/blog/query-keys.d.mts +8 -387
  103. package/dist/plugins/blog/query-keys.d.ts +8 -387
  104. package/dist/plugins/blog/query-keys.mjs +6 -5
  105. package/dist/plugins/client/index.cjs +1 -0
  106. package/dist/plugins/client/index.d.cts +8 -1
  107. package/dist/plugins/client/index.d.mts +8 -1
  108. package/dist/plugins/client/index.d.ts +8 -1
  109. package/dist/plugins/client/index.mjs +1 -1
  110. package/dist/plugins/cms/api/index.cjs +8 -0
  111. package/dist/plugins/cms/api/index.d.cts +7 -219
  112. package/dist/plugins/cms/api/index.d.mts +7 -219
  113. package/dist/plugins/cms/api/index.d.ts +7 -219
  114. package/dist/plugins/cms/api/index.mjs +3 -1
  115. package/dist/plugins/cms/client/hooks/index.d.cts +1 -1
  116. package/dist/plugins/cms/client/hooks/index.d.mts +1 -1
  117. package/dist/plugins/cms/client/hooks/index.d.ts +1 -1
  118. package/dist/plugins/cms/query-keys.cjs +2 -1
  119. package/dist/plugins/cms/query-keys.d.cts +5 -9
  120. package/dist/plugins/cms/query-keys.d.mts +5 -9
  121. package/dist/plugins/cms/query-keys.d.ts +5 -9
  122. package/dist/plugins/cms/query-keys.mjs +2 -1
  123. package/dist/plugins/form-builder/api/index.cjs +6 -0
  124. package/dist/plugins/form-builder/api/index.d.cts +7 -211
  125. package/dist/plugins/form-builder/api/index.d.mts +7 -211
  126. package/dist/plugins/form-builder/api/index.d.ts +7 -211
  127. package/dist/plugins/form-builder/api/index.mjs +2 -1
  128. package/dist/plugins/form-builder/client/components/index.d.cts +1 -1
  129. package/dist/plugins/form-builder/client/components/index.d.mts +1 -1
  130. package/dist/plugins/form-builder/client/components/index.d.ts +1 -1
  131. package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
  132. package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
  133. package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
  134. package/dist/plugins/form-builder/query-keys.cjs +3 -2
  135. package/dist/plugins/form-builder/query-keys.d.cts +6 -6
  136. package/dist/plugins/form-builder/query-keys.d.mts +6 -6
  137. package/dist/plugins/form-builder/query-keys.d.ts +6 -6
  138. package/dist/plugins/form-builder/query-keys.mjs +3 -2
  139. package/dist/plugins/kanban/api/index.cjs +10 -0
  140. package/dist/plugins/kanban/api/index.d.cts +17 -392
  141. package/dist/plugins/kanban/api/index.d.mts +17 -392
  142. package/dist/plugins/kanban/api/index.d.ts +17 -392
  143. package/dist/plugins/kanban/api/index.mjs +3 -0
  144. package/dist/plugins/kanban/client/components/index.d.cts +1 -1
  145. package/dist/plugins/kanban/client/components/index.d.mts +1 -1
  146. package/dist/plugins/kanban/client/components/index.d.ts +1 -1
  147. package/dist/plugins/kanban/client/hooks/index.d.cts +1 -1
  148. package/dist/plugins/kanban/client/hooks/index.d.mts +1 -1
  149. package/dist/plugins/kanban/client/hooks/index.d.ts +1 -1
  150. package/dist/plugins/kanban/client/index.d.cts +1 -1
  151. package/dist/plugins/kanban/client/index.d.mts +1 -1
  152. package/dist/plugins/kanban/client/index.d.ts +1 -1
  153. package/dist/plugins/kanban/query-keys.cjs +2 -9
  154. package/dist/plugins/kanban/query-keys.d.cts +4 -16
  155. package/dist/plugins/kanban/query-keys.d.mts +4 -16
  156. package/dist/plugins/kanban/query-keys.d.ts +4 -16
  157. package/dist/plugins/kanban/query-keys.mjs +2 -9
  158. package/dist/plugins/ui-builder/index.d.cts +1 -1
  159. package/dist/plugins/ui-builder/index.d.mts +1 -1
  160. package/dist/plugins/ui-builder/index.d.ts +1 -1
  161. package/dist/shared/stack.B7ONvlD_.d.mts +293 -0
  162. package/dist/shared/{stack.BeSm90va.d.ts → stack.BEn34wW6.d.ts} +60 -2
  163. package/dist/shared/stack.BUkC2EsZ.d.cts +327 -0
  164. package/dist/shared/{stack.DaOcgmrM.d.ts → stack.BV9hnvu4.d.cts} +31 -7
  165. package/dist/shared/{stack.DaOcgmrM.d.cts → stack.BV9hnvu4.d.mts} +31 -7
  166. package/dist/shared/{stack.DaOcgmrM.d.mts → stack.BV9hnvu4.d.ts} +31 -7
  167. package/dist/shared/stack.BepFXT3w.d.mts +500 -0
  168. package/dist/shared/stack.CL8ts1Mu.d.ts +419 -0
  169. package/dist/shared/{stack.CXjzTMsb.d.cts → stack.CVDTkMoO.d.cts} +7 -1
  170. package/dist/shared/{stack.CXjzTMsb.d.mts → stack.CVDTkMoO.d.mts} +7 -1
  171. package/dist/shared/{stack.CXjzTMsb.d.ts → stack.CVDTkMoO.d.ts} +7 -1
  172. package/dist/shared/stack.CczspVn2.d.mts +327 -0
  173. package/dist/shared/stack.CgWzG5jH.d.ts +500 -0
  174. package/dist/shared/stack.D3GB6wKv.d.cts +500 -0
  175. package/dist/shared/stack.DASmUVjX.d.ts +327 -0
  176. package/dist/shared/{stack.QD1y_7NY.d.cts → stack.DJaKVY7v.d.cts} +1 -1
  177. package/dist/shared/{stack.QD1y_7NY.d.mts → stack.DJaKVY7v.d.mts} +1 -1
  178. package/dist/shared/{stack.QD1y_7NY.d.ts → stack.DJaKVY7v.d.ts} +1 -1
  179. package/dist/shared/{stack.Dg09R0oB.d.mts → stack.DTDxgFj8.d.mts} +60 -2
  180. package/dist/shared/{stack.CMh_EdxW.d.cts → stack.DWoCZff7.d.cts} +60 -2
  181. package/dist/shared/{stack.CIrIsc-A.d.cts → stack.DdI5W6MB.d.cts} +7 -1
  182. package/dist/shared/{stack.CIrIsc-A.d.mts → stack.DdI5W6MB.d.mts} +7 -1
  183. package/dist/shared/{stack.CIrIsc-A.d.ts → stack.DdI5W6MB.d.ts} +7 -1
  184. package/dist/shared/stack.Dk5r4W1F.d.mts +419 -0
  185. package/dist/shared/stack.Kq2-QzOC.d.ts +293 -0
  186. package/dist/shared/stack.heOA9gzA.d.cts +419 -0
  187. package/dist/shared/stack.kcdnD4gA.d.cts +293 -0
  188. package/package.json +16 -3
  189. package/src/client/components/compose.tsx +7 -4
  190. package/src/plugins/ai-chat/api/page-tools.ts +111 -0
  191. package/src/plugins/ai-chat/api/plugin.ts +180 -9
  192. package/src/plugins/ai-chat/client/components/chat-input.tsx +2 -2
  193. package/src/plugins/ai-chat/client/components/chat-interface.tsx +154 -58
  194. package/src/plugins/ai-chat/client/components/chat-layout.tsx +166 -32
  195. package/src/plugins/ai-chat/client/components/chat-sidebar.tsx +1 -1
  196. package/src/plugins/ai-chat/client/context/page-ai-context.tsx +240 -0
  197. package/src/plugins/ai-chat/schemas.ts +16 -0
  198. package/src/plugins/blog/api/index.ts +2 -0
  199. package/src/plugins/blog/api/plugin.ts +85 -0
  200. package/src/plugins/blog/api/query-key-defs.ts +46 -0
  201. package/src/plugins/blog/api/serializers.ts +27 -0
  202. package/src/plugins/blog/client/components/forms/post-forms.tsx +29 -2
  203. package/src/plugins/blog/client/components/pages/edit-post-page.internal.tsx +28 -0
  204. package/src/plugins/blog/client/components/pages/fill-blog-form-handler.ts +38 -0
  205. package/src/plugins/blog/client/components/pages/new-post-page.internal.tsx +33 -1
  206. package/src/plugins/blog/client/components/pages/post-page.internal.tsx +20 -0
  207. package/src/plugins/blog/client/plugin.tsx +19 -0
  208. package/src/plugins/blog/query-keys.ts +5 -7
  209. package/src/plugins/client/index.ts +1 -1
  210. package/src/plugins/cms/api/getters.ts +24 -0
  211. package/src/plugins/cms/api/index.ts +14 -1
  212. package/src/plugins/cms/api/mutations.ts +84 -0
  213. package/src/plugins/cms/api/plugin.ts +114 -0
  214. package/src/plugins/cms/api/query-key-defs.ts +53 -0
  215. package/src/plugins/cms/api/serializers.ts +12 -0
  216. package/src/plugins/cms/client/plugin.tsx +19 -0
  217. package/src/plugins/cms/query-keys.ts +2 -1
  218. package/src/plugins/form-builder/api/getters.ts +23 -0
  219. package/src/plugins/form-builder/api/index.ts +15 -2
  220. package/src/plugins/form-builder/api/plugin.ts +91 -0
  221. package/src/plugins/form-builder/api/query-key-defs.ts +79 -0
  222. package/src/plugins/form-builder/api/serializers.ts +12 -0
  223. package/src/plugins/form-builder/client/plugin.tsx +19 -0
  224. package/src/plugins/form-builder/query-keys.ts +6 -2
  225. package/src/plugins/kanban/api/index.ts +9 -0
  226. package/src/plugins/kanban/api/mutations.ts +169 -0
  227. package/src/plugins/kanban/api/plugin.ts +61 -0
  228. package/src/plugins/kanban/api/query-key-defs.ts +54 -0
  229. package/src/plugins/kanban/api/serializers.ts +49 -0
  230. package/src/plugins/kanban/client/hooks/kanban-hooks.tsx +4 -0
  231. package/src/plugins/kanban/client/plugin.tsx +13 -0
  232. package/src/plugins/kanban/query-keys.ts +2 -9
  233. package/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.tsx +132 -0
  234. package/src/plugins/utils.ts +19 -0
  235. package/dist/shared/{stack.BkYlUT_8.d.cts → stack.BQmuNl5p.d.cts} +6 -6
  236. package/dist/shared/{stack.BkYlUT_8.d.mts → stack.BQmuNl5p.d.mts} +6 -6
  237. package/dist/shared/{stack.BkYlUT_8.d.ts → stack.BQmuNl5p.d.ts} +6 -6
@@ -2,57 +2,116 @@
2
2
 
3
3
  import { useState, useCallback } from "react";
4
4
  import { Button } from "@workspace/ui/components/button";
5
+ import { Badge } from "@workspace/ui/components/badge";
5
6
  import {
6
7
  Sheet,
7
8
  SheetContent,
8
9
  SheetTrigger,
9
10
  } from "@workspace/ui/components/sheet";
10
- import { Menu, PanelLeftClose, PanelLeft } from "lucide-react";
11
+ import {
12
+ Menu,
13
+ PanelLeftClose,
14
+ PanelLeft,
15
+ Sparkles,
16
+ Trash2,
17
+ X,
18
+ } from "lucide-react";
11
19
  import { cn } from "@workspace/ui/lib/utils";
12
20
  import { ChatSidebar } from "./chat-sidebar";
13
21
  import { ChatInterface } from "./chat-interface";
14
22
  import type { UIMessage } from "ai";
23
+ import { usePageAIContext } from "../context/page-ai-context";
15
24
 
16
- export interface ChatLayoutProps {
25
+ interface ChatLayoutBaseProps {
17
26
  /** API base URL */
18
27
  apiBaseURL: string;
19
28
  /** API base path */
20
29
  apiBasePath: string;
21
30
  /** Current conversation ID (if viewing existing conversation) */
22
31
  conversationId?: string;
23
- /** Layout mode: 'full' for full page with sidebar, 'widget' for embeddable widget */
24
- layout?: "full" | "widget";
25
32
  /** Additional class name for the container */
26
33
  className?: string;
27
- /** Whether to show the sidebar (default: true for full layout) */
34
+ /** Whether to show the sidebar */
28
35
  showSidebar?: boolean;
29
- /** Height of the widget (only applies to widget layout) */
30
- widgetHeight?: string | number;
31
36
  /** Initial messages to populate the chat (useful for localStorage persistence in public mode) */
32
37
  initialMessages?: UIMessage[];
33
38
  /** Called whenever messages change (for persistence). Only fires in public mode. */
34
39
  onMessagesChange?: (messages: UIMessage[]) => void;
35
40
  }
36
41
 
42
+ interface ChatLayoutWidgetProps extends ChatLayoutBaseProps {
43
+ /** Widget mode: compact embeddable panel with a floating trigger button */
44
+ layout: "widget";
45
+ /** Height of the widget panel. Default: `"600px"` */
46
+ widgetHeight?: string | number;
47
+ /** Width of the widget panel. Default: `"380px"` */
48
+ widgetWidth?: string | number;
49
+ /**
50
+ * Whether the widget panel starts open. Default: `false`.
51
+ * Set to `true` when embedding inside an already-open container such as a
52
+ * Next.js intercepting-route modal — the panel will be immediately visible
53
+ * without the user needing to click the trigger button.
54
+ */
55
+ defaultOpen?: boolean;
56
+ /**
57
+ * Whether to render the built-in floating trigger button. Default: `true`.
58
+ * Set to `false` when you control open/close externally (e.g. a Next.js
59
+ * parallel-route slot, a custom button, or a `router.back()` dismiss action)
60
+ * so that the built-in button does not appear alongside your own UI.
61
+ */
62
+ showTrigger?: boolean;
63
+ }
64
+
65
+ interface ChatLayoutFullProps extends ChatLayoutBaseProps {
66
+ /** Full-page mode with sidebar navigation (default) */
67
+ layout?: "full";
68
+ }
69
+
70
+ /** Props for the ChatLayout component */
71
+ export type ChatLayoutProps = ChatLayoutWidgetProps | ChatLayoutFullProps;
72
+
37
73
  /**
38
74
  * ChatLayout component that provides a full-page chat experience with sidebar
39
75
  * or a compact widget mode for embedding.
40
76
  */
41
- export function ChatLayout({
42
- apiBaseURL,
43
- apiBasePath,
44
- conversationId,
45
- layout = "full",
46
- className,
47
- showSidebar = true,
48
- widgetHeight = "600px",
49
- initialMessages,
50
- onMessagesChange,
51
- }: ChatLayoutProps) {
77
+ export function ChatLayout(props: ChatLayoutProps) {
78
+ const {
79
+ apiBaseURL,
80
+ apiBasePath,
81
+ conversationId,
82
+ layout = "full",
83
+ className,
84
+ showSidebar = true,
85
+ initialMessages,
86
+ onMessagesChange,
87
+ } = props;
88
+
89
+ // Widget-specific props — TypeScript narrows props to ChatLayoutWidgetProps here
90
+ const widgetHeight =
91
+ props.layout === "widget" ? (props.widgetHeight ?? "600px") : "600px";
92
+ const widgetWidth =
93
+ props.layout === "widget" ? (props.widgetWidth ?? "380px") : "380px";
94
+ const defaultOpen =
95
+ props.layout === "widget" ? (props.defaultOpen ?? false) : false;
96
+ const showTrigger =
97
+ props.layout === "widget" ? (props.showTrigger ?? true) : true;
98
+
52
99
  const [sidebarOpen, setSidebarOpen] = useState(true);
53
100
  const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
54
101
  // Key to force ChatInterface remount when starting a new chat
55
102
  const [chatResetKey, setChatResetKey] = useState(0);
103
+ // Widget open/closed state — starts with defaultOpen value
104
+ const [widgetOpen, setWidgetOpen] = useState(defaultOpen);
105
+ // Key to force widget ChatInterface remount on clear
106
+ const [widgetResetKey, setWidgetResetKey] = useState(0);
107
+ // Only mount the widget ChatInterface after the widget has been opened at least once.
108
+ // This ensures pageAIContext is already registered before ChatInterface first renders,
109
+ // so suggestion chips and tool hints appear immediately on first open.
110
+ // When defaultOpen is true the widget is pre-opened, so we mark it as ever-opened immediately.
111
+ const [widgetEverOpened, setWidgetEverOpened] = useState(defaultOpen);
112
+
113
+ // Read page AI context to show badge in header
114
+ const pageAIContext = usePageAIContext();
56
115
 
57
116
  const apiPath = `${apiBaseURL}${apiBasePath}/chat`;
58
117
 
@@ -67,20 +126,83 @@ export function ChatLayout({
67
126
 
68
127
  if (layout === "widget") {
69
128
  return (
70
- <div
71
- className={cn(
72
- "flex flex-col w-full border rounded-xl overflow-hidden bg-background shadow-sm",
73
- className,
129
+ <div className={cn("flex flex-col items-end gap-3", className)}>
130
+ {/* Chat panel — always mounted to preserve conversation state, hidden when closed */}
131
+ <div
132
+ className={cn(
133
+ "flex flex-col border rounded-xl overflow-hidden bg-background shadow-xl",
134
+ widgetOpen ? "flex" : "hidden",
135
+ )}
136
+ style={{ height: widgetHeight, width: widgetWidth }}
137
+ >
138
+ {/* Widget header with page context badge and action buttons */}
139
+ <div className="flex items-center gap-1.5 px-3 py-1.5 border-b bg-muted/40">
140
+ <Sparkles className="h-3 w-3 text-muted-foreground" />
141
+ {pageAIContext ? (
142
+ <Badge
143
+ variant="secondary"
144
+ className="text-xs"
145
+ data-testid="page-context-badge"
146
+ >
147
+ {pageAIContext.routeName}
148
+ </Badge>
149
+ ) : (
150
+ <span className="text-xs text-muted-foreground font-medium">
151
+ AI Chat
152
+ </span>
153
+ )}
154
+ <div className="flex-1" />
155
+ <Button
156
+ variant="ghost"
157
+ size="icon"
158
+ className="h-5 w-5"
159
+ onClick={() => setWidgetResetKey((prev) => prev + 1)}
160
+ aria-label="Clear chat"
161
+ title="Clear chat"
162
+ >
163
+ <Trash2 className="h-3.5 w-3.5" />
164
+ </Button>
165
+ <Button
166
+ variant="ghost"
167
+ size="icon"
168
+ className="h-5 w-5"
169
+ onClick={() => setWidgetOpen(false)}
170
+ aria-label="Close chat"
171
+ >
172
+ <X className="h-3.5 w-3.5" />
173
+ </Button>
174
+ </div>
175
+ {widgetEverOpened && (
176
+ <ChatInterface
177
+ key={`widget-${conversationId ?? "new"}-${widgetResetKey}`}
178
+ apiPath={apiPath}
179
+ id={conversationId}
180
+ variant="widget"
181
+ initialMessages={initialMessages}
182
+ onMessagesChange={onMessagesChange}
183
+ />
184
+ )}
185
+ </div>
186
+
187
+ {/* Trigger button — rendered only when showTrigger is true */}
188
+ {showTrigger && (
189
+ <Button
190
+ size="icon"
191
+ className="h-12 w-12 rounded-full shadow-lg"
192
+ onClick={() => {
193
+ setWidgetOpen((prev) => !prev);
194
+ setWidgetEverOpened(true);
195
+ }}
196
+ aria-label={widgetOpen ? "Close chat" : "Open chat"}
197
+ data-testid="widget-trigger"
198
+ >
199
+ {widgetOpen ? (
200
+ <X className="h-5 w-5" />
201
+ ) : (
202
+ <Sparkles className="h-5 w-5" />
203
+ )}
204
+ </Button>
74
205
  )}
75
- style={{ height: widgetHeight }}
76
- >
77
- <ChatInterface
78
- apiPath={apiPath}
79
- id={conversationId}
80
- variant="widget"
81
- initialMessages={initialMessages}
82
- onMessagesChange={onMessagesChange}
83
- />
84
206
  </div>
85
207
  );
86
208
  }
@@ -115,7 +237,7 @@ export function ChatLayout({
115
237
  {/* Main Chat Area */}
116
238
  <div className="flex-1 flex flex-col min-w-0">
117
239
  {/* Header */}
118
- <div className="flex items-center gap-2 p-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
240
+ <div className="flex items-center gap-2 p-2 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
119
241
  {/* Mobile menu button */}
120
242
  {showSidebar && (
121
243
  <Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
@@ -159,6 +281,18 @@ export function ChatLayout({
159
281
  )}
160
282
 
161
283
  <div className="flex-1" />
284
+
285
+ {/* Page context badge — shown when a page has registered AI context */}
286
+ {pageAIContext && (
287
+ <Badge
288
+ variant="secondary"
289
+ className="text-xs gap-1 mr-2"
290
+ data-testid="page-context-badge"
291
+ >
292
+ <Sparkles className="h-3 w-3" />
293
+ {pageAIContext.routeName}
294
+ </Badge>
295
+ )}
162
296
  </div>
163
297
 
164
298
  <ChatInterface
@@ -163,7 +163,7 @@ export function ChatSidebar({
163
163
  </div>
164
164
 
165
165
  {/* Conversations List */}
166
- <ScrollArea className="flex-1">
166
+ <ScrollArea className="flex-1 [&_[data-slot=scroll-area-viewport]>div]:!block">
167
167
  <div className="p-2">
168
168
  {isLoading ? (
169
169
  <div className="p-4 text-center text-sm text-muted-foreground">
@@ -0,0 +1,240 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useId,
8
+ useEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ } from "react";
13
+
14
+ /**
15
+ * A client-side tool handler — receives the AI's tool call args and returns a result.
16
+ * The result is sent back to the model so it can continue the conversation.
17
+ */
18
+ export type PageAIClientTool = (
19
+ args: any,
20
+ ) => Promise<{ success: boolean; message?: string }>;
21
+
22
+ /**
23
+ * Configuration registered by a page to provide AI context and capabilities.
24
+ * Any component in the tree can call useRegisterPageAIContext with this config.
25
+ */
26
+ export interface PageAIContextConfig {
27
+ /**
28
+ * Identifier for the current route/page (e.g. "blog-post", "ui-builder-edit-page").
29
+ * Shown as a badge in the chat header.
30
+ */
31
+ routeName: string;
32
+
33
+ /**
34
+ * Human-readable description of the current page and its content.
35
+ * Injected into the AI system prompt so it understands what the user is looking at.
36
+ * Capped at 8,000 characters server-side.
37
+ */
38
+ pageDescription: string;
39
+
40
+ /**
41
+ * Optional suggested prompts shown as quick-action chips in the chat empty state.
42
+ * These augment (not replace) any static suggestions configured in plugin overrides.
43
+ */
44
+ suggestions?: string[];
45
+
46
+ /**
47
+ * Client-side tool handlers keyed by tool name.
48
+ * When the AI calls a tool by this name, the handler is invoked with the tool args.
49
+ * The result is sent back to the model via addToolResult.
50
+ *
51
+ * Tool schemas must be registered server-side via enablePageTools + clientToolSchemas
52
+ * in aiChatBackendPlugin (built-in tools like fillBlogForm are pre-registered).
53
+ */
54
+ clientTools?: Record<string, PageAIClientTool>;
55
+ }
56
+
57
+ interface PageAIAPIContextValue {
58
+ register: (id: string, config: PageAIContextConfig) => void;
59
+ unregister: (id: string) => void;
60
+ getActive: () => PageAIContextConfig | null;
61
+ }
62
+
63
+ /**
64
+ * Stable API context — holds register/unregister/getActive.
65
+ * Never changes reference, so useRegisterPageAIContext effects don't re-run
66
+ * simply because the provider re-rendered after a bumpVersion call.
67
+ */
68
+ const PageAIAPIContext = createContext<PageAIAPIContextValue | null>(null);
69
+
70
+ /**
71
+ * Reactive version context — incremented on every register/unregister.
72
+ * Consumers of usePageAIContext subscribe here so they re-render when
73
+ * registrations change and re-call getActive() to pick up the latest config.
74
+ */
75
+ const PageAIVersionContext = createContext<number>(0);
76
+
77
+ /**
78
+ * Provider that enables route-aware AI context across the app.
79
+ *
80
+ * Place this at the root layout — above all StackProviders — so it spans
81
+ * both your main app tree and any chat modals rendered as parallel/intercept routes.
82
+ *
83
+ * @example
84
+ * // app/layout.tsx
85
+ * import { PageAIContextProvider } from "@btst/stack/plugins/ai-chat/client/context"
86
+ *
87
+ * export default function RootLayout({ children }) {
88
+ * return <PageAIContextProvider>{children}</PageAIContextProvider>
89
+ * }
90
+ */
91
+ export function PageAIContextProvider({
92
+ children,
93
+ }: {
94
+ children: React.ReactNode;
95
+ }) {
96
+ // Map from stable registration id → config
97
+ // Using useRef so mutations don't trigger re-renders of the provider itself
98
+ const registrationsRef = useRef<Map<string, PageAIContextConfig>>(new Map());
99
+ // Track insertion order so the last-registered (most specific) wins
100
+ const insertionOrderRef = useRef<string[]>([]);
101
+
102
+ // Version counter — bumped on every register/unregister so consumers re-read
103
+ const [version, setVersion] = useState(0);
104
+ const bumpVersion = useCallback(() => setVersion((v) => v + 1), []);
105
+
106
+ const register = useCallback(
107
+ (id: string, config: PageAIContextConfig) => {
108
+ registrationsRef.current.set(id, config);
109
+ // Move to end to mark as most recent
110
+ insertionOrderRef.current = insertionOrderRef.current.filter(
111
+ (k) => k !== id,
112
+ );
113
+ insertionOrderRef.current.push(id);
114
+ bumpVersion();
115
+ },
116
+ [bumpVersion],
117
+ );
118
+
119
+ const unregister = useCallback(
120
+ (id: string) => {
121
+ registrationsRef.current.delete(id);
122
+ insertionOrderRef.current = insertionOrderRef.current.filter(
123
+ (k) => k !== id,
124
+ );
125
+ bumpVersion();
126
+ },
127
+ [bumpVersion],
128
+ );
129
+
130
+ const getActive = useCallback((): PageAIContextConfig | null => {
131
+ const order = insertionOrderRef.current;
132
+ if (order.length === 0) return null;
133
+ // Last registered wins (most deeply nested / most recently mounted)
134
+ const lastId = order[order.length - 1];
135
+ if (!lastId) return null;
136
+ return registrationsRef.current.get(lastId) ?? null;
137
+ }, []);
138
+
139
+ // Memoize the API object so its reference never changes — this is what
140
+ // breaks the infinite loop: useRegisterPageAIContext has `ctx` (the API)
141
+ // in its effect deps, and a stable reference means the effect won't re-run
142
+ // just because the provider re-rendered after bumpVersion().
143
+ const api = useMemo(
144
+ () => ({ register, unregister, getActive }),
145
+ [register, unregister, getActive],
146
+ );
147
+
148
+ return (
149
+ <PageAIAPIContext.Provider value={api}>
150
+ <PageAIVersionContext.Provider value={version}>
151
+ {children}
152
+ </PageAIVersionContext.Provider>
153
+ </PageAIAPIContext.Provider>
154
+ );
155
+ }
156
+
157
+ /**
158
+ * Register page AI context from any component.
159
+ * The registration is cleaned up automatically when the component unmounts.
160
+ *
161
+ * Pass `null` to conditionally disable context (e.g. while data is loading).
162
+ *
163
+ * @example
164
+ * // Blog post page
165
+ * useRegisterPageAIContext(post ? {
166
+ * routeName: "blog-post",
167
+ * pageDescription: `Blog post: "${post.title}"\n\n${post.content?.slice(0, 16000)}`,
168
+ * suggestions: ["Summarize this post", "What are the key takeaways?"],
169
+ * } : null)
170
+ */
171
+ export function useRegisterPageAIContext(
172
+ config: PageAIContextConfig | null,
173
+ ): void {
174
+ // Use the stable API context — its reference never changes, so adding it
175
+ // to the dependency array below does NOT cause the effect to re-run after
176
+ // bumpVersion() fires. This breaks the register → bumpVersion → re-render
177
+ // → effect re-run → register loop that caused "Maximum update depth exceeded".
178
+ const ctx = useContext(PageAIAPIContext);
179
+ const id = useId();
180
+
181
+ // Always keep the ref current so clientTools handlers are never stale.
182
+ // Updating a ref during render is safe — the value is visible to any effect
183
+ // that runs in the same commit.
184
+ const configRef = useRef<PageAIContextConfig | null>(config);
185
+ configRef.current = config;
186
+
187
+ useEffect(() => {
188
+ if (!ctx || !configRef.current) return;
189
+ // Register a live proxy that always reads from configRef. This ensures
190
+ // clientTools handlers are fresh even when the effect doesn't re-run —
191
+ // for example when a handler's closure captures new state but the
192
+ // serializable fields (routeName, pageDescription, suggestions) are unchanged.
193
+ // JSON.stringify silently strips function values, so clientTools would be
194
+ // invisible to a plain JSON.stringify(config) dependency check.
195
+ ctx.register(id, {
196
+ get routeName() {
197
+ return configRef.current?.routeName ?? "";
198
+ },
199
+ get pageDescription() {
200
+ return configRef.current?.pageDescription ?? "";
201
+ },
202
+ get suggestions() {
203
+ return configRef.current?.suggestions;
204
+ },
205
+ get clientTools() {
206
+ return configRef.current?.clientTools;
207
+ },
208
+ });
209
+ return () => {
210
+ ctx.unregister(id);
211
+ };
212
+ // Track serializable fields individually. JSON.stringify on the whole config
213
+ // would silently strip clientTools (functions), making handler changes invisible.
214
+ // Handler freshness is provided by the ref-proxy above instead.
215
+ // eslint-disable-next-line react-hooks/exhaustive-deps
216
+ }, [
217
+ ctx,
218
+ id,
219
+ config === null,
220
+ config?.routeName,
221
+ config?.pageDescription,
222
+ JSON.stringify(config?.suggestions),
223
+ ]);
224
+ }
225
+
226
+ /**
227
+ * Read the currently active page AI context.
228
+ * Returns null when no page has registered context, or when PageAIContextProvider
229
+ * is not in the tree.
230
+ *
231
+ * Used internally by ChatInterface to inject context into requests.
232
+ */
233
+ export function usePageAIContext(): PageAIContextConfig | null {
234
+ // Subscribe to the version counter so this hook re-runs whenever a page
235
+ // registers or unregisters context, then read the latest active config.
236
+ useContext(PageAIVersionContext);
237
+ const ctx = useContext(PageAIAPIContext);
238
+ if (!ctx) return null;
239
+ return ctx.getActive();
240
+ }
@@ -37,4 +37,20 @@ export const chatRequestSchema = z.object({
37
37
  ),
38
38
  conversationId: z.string().optional(),
39
39
  model: z.string().optional(),
40
+ /**
41
+ * Description of the current page context, injected into the AI system prompt.
42
+ * Sent by ChatInterface when a page has registered context via useRegisterPageAIContext.
43
+ */
44
+ pageContext: z.string().max(16000).optional(),
45
+ /**
46
+ * Names of client-side tools currently available on the page.
47
+ * The server includes matching tool schemas in the streamText call.
48
+ */
49
+ availableTools: z.array(z.string()).optional(),
50
+ /**
51
+ * The routeName registered by the page via useRegisterPageAIContext.
52
+ * Cross-validated server-side against each built-in tool's route allowlist
53
+ * to prevent a page from claiming tools intended for a different route.
54
+ */
55
+ routeName: z.string().optional(),
40
56
  });
@@ -6,4 +6,6 @@ export {
6
6
  type PostListParams,
7
7
  type PostListResult,
8
8
  } from "./getters";
9
+ export { serializePost, serializeTag } from "./serializers";
10
+ export { BLOG_QUERY_KEYS } from "./query-key-defs";
9
11
  export { createBlogQueryKeys } from "../query-keys";
@@ -7,6 +7,90 @@ import type { Post, PostWithPostTag, Tag } from "../types";
7
7
  import { slugify } from "../utils";
8
8
  import { createPostSchema, updatePostSchema } from "../schemas";
9
9
  import { getAllPosts, getPostBySlug, getAllTags } from "./getters";
10
+ import { BLOG_QUERY_KEYS } from "./query-key-defs";
11
+ import { serializePost, serializeTag } from "./serializers";
12
+ import type { QueryClient } from "@tanstack/react-query";
13
+
14
+ /**
15
+ * Route keys for the blog plugin — matches the keys returned by
16
+ * `stackClient.router.getRoute(path).routeKey`.
17
+ */
18
+ export type BlogRouteKey =
19
+ | "posts"
20
+ | "drafts"
21
+ | "post"
22
+ | "tag"
23
+ | "newPost"
24
+ | "editPost";
25
+
26
+ /**
27
+ * Overloaded signature for `prefetchForRoute`.
28
+ * TypeScript enforces the correct params for each routeKey at call sites.
29
+ */
30
+ interface BlogPrefetchForRoute {
31
+ (key: "posts" | "drafts" | "newPost", qc: QueryClient): Promise<void>;
32
+ (
33
+ key: "post" | "editPost",
34
+ qc: QueryClient,
35
+ params: { slug: string },
36
+ ): Promise<void>;
37
+ (key: "tag", qc: QueryClient, params: { tagSlug: string }): Promise<void>;
38
+ }
39
+
40
+ function createBlogPrefetchForRoute(adapter: Adapter): BlogPrefetchForRoute {
41
+ return async function prefetchForRoute(
42
+ key: BlogRouteKey,
43
+ qc: QueryClient,
44
+ params?: Record<string, string>,
45
+ ): Promise<void> {
46
+ switch (key) {
47
+ case "posts":
48
+ case "drafts": {
49
+ const published = key === "posts";
50
+ const [result, tags] = await Promise.all([
51
+ getAllPosts(adapter, { published, limit: 10 }),
52
+ getAllTags(adapter),
53
+ ]);
54
+ qc.setQueryData(BLOG_QUERY_KEYS.postsList({ published, limit: 10 }), {
55
+ pages: [result.items.map(serializePost)],
56
+ pageParams: [0],
57
+ });
58
+ qc.setQueryData(BLOG_QUERY_KEYS.tagsList(), tags.map(serializeTag));
59
+ break;
60
+ }
61
+ case "post":
62
+ case "editPost": {
63
+ const slug = params?.slug ?? "";
64
+ if (slug) {
65
+ const post = await getPostBySlug(adapter, slug);
66
+ qc.setQueryData(
67
+ BLOG_QUERY_KEYS.postDetail(slug),
68
+ post ? serializePost(post) : null,
69
+ );
70
+ }
71
+ break;
72
+ }
73
+ case "tag": {
74
+ const tagSlug = params?.tagSlug ?? "";
75
+ const [result, tags] = await Promise.all([
76
+ getAllPosts(adapter, { published: true, limit: 10, tagSlug }),
77
+ getAllTags(adapter),
78
+ ]);
79
+ qc.setQueryData(
80
+ BLOG_QUERY_KEYS.postsList({ published: true, limit: 10, tagSlug }),
81
+ {
82
+ pages: [result.items.map(serializePost)],
83
+ pageParams: [0],
84
+ },
85
+ );
86
+ qc.setQueryData(BLOG_QUERY_KEYS.tagsList(), tags.map(serializeTag));
87
+ break;
88
+ }
89
+ default:
90
+ break;
91
+ }
92
+ } as BlogPrefetchForRoute;
93
+ }
10
94
 
11
95
  export const PostListQuerySchema = z.object({
12
96
  slug: z.string().optional(),
@@ -174,6 +258,7 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
174
258
  getAllPosts(adapter, params),
175
259
  getPostBySlug: (slug: string) => getPostBySlug(adapter, slug),
176
260
  getAllTags: () => getAllTags(adapter),
261
+ prefetchForRoute: createBlogPrefetchForRoute(adapter),
177
262
  }),
178
263
 
179
264
  routes: (adapter: Adapter) => {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Internal query key constants for the blog plugin.
3
+ * Shared between query-keys.ts (HTTP path) and prefetchForRoute (DB path)
4
+ * to prevent key drift between SSR loaders and SSG prefetching.
5
+ */
6
+
7
+ export interface PostsListDiscriminator {
8
+ query: string | undefined;
9
+ limit: number;
10
+ published: boolean;
11
+ tagSlug: string | undefined;
12
+ }
13
+
14
+ /**
15
+ * Builds the discriminator object used as the cache key for the posts list.
16
+ * Mirrors the inline object in createPostsQueries so both paths stay in sync.
17
+ */
18
+ export function postsListDiscriminator(params: {
19
+ published: boolean;
20
+ limit?: number;
21
+ tagSlug?: string;
22
+ query?: string;
23
+ }): PostsListDiscriminator {
24
+ return {
25
+ query:
26
+ params.query !== undefined && params.query.trim() === ""
27
+ ? undefined
28
+ : params.query,
29
+ limit: params.limit ?? 10,
30
+ published: params.published,
31
+ tagSlug: params.tagSlug,
32
+ };
33
+ }
34
+
35
+ /** Full query key builders — use these with queryClient.setQueryData() */
36
+ export const BLOG_QUERY_KEYS = {
37
+ postsList: (params: {
38
+ published: boolean;
39
+ limit?: number;
40
+ tagSlug?: string;
41
+ }) => ["posts", "list", postsListDiscriminator(params)] as const,
42
+
43
+ postDetail: (slug: string) => ["posts", "detail", slug] as const,
44
+
45
+ tagsList: () => ["tags", "list", "tags"] as const,
46
+ };