@kitn.ai/ui 0.25.1 → 0.26.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 (409) hide show
  1. package/README.md +5 -7
  2. package/dist/components/attachment-types.d.ts +46 -1
  3. package/dist/components/attachments.d.ts +31 -0
  4. package/dist/components/audio-visualizer/fit-scale.d.ts +50 -0
  5. package/dist/components/audio-visualizer/index.d.ts +15 -1
  6. package/dist/components/audio-visualizer/variant-bar.d.ts +24 -1
  7. package/dist/components/chat-thread.d.ts +3 -41
  8. package/dist/components/code-block.d.ts +22 -0
  9. package/dist/components/conversation-item.d.ts +54 -1
  10. package/dist/components/conversation-list.d.ts +65 -0
  11. package/dist/components/form.d.ts +42 -0
  12. package/dist/components/message.d.ts +7 -0
  13. package/dist/components/toast.d.ts +2 -1
  14. package/dist/components/voice-input.d.ts +8 -0
  15. package/dist/components/voice-output.d.ts +12 -1
  16. package/dist/components/workspace-shell.d.ts +87 -0
  17. package/dist/{create-tween-B5Y4Im8G.js → create-tween-BFT6c-Y8.js} +1 -1
  18. package/dist/{create-tween-4z1XYLkR.js → create-tween-CJNlzoaO.js} +1 -1
  19. package/dist/{create-tween-CUzOlaMS.js → create-tween-CQED4T0g.js} +1 -1
  20. package/dist/custom-elements.json +2931 -2572
  21. package/dist/diagnostics/hook.d.ts +81 -0
  22. package/dist/diagnostics/index.d.ts +7 -0
  23. package/dist/diagnostics/report-request.d.ts +61 -0
  24. package/dist/diagnostics.d.ts +5 -0
  25. package/dist/diagnostics.js +209 -0
  26. package/dist/elements/agent-card.js +1 -1
  27. package/dist/elements/artifact.js +1 -1
  28. package/dist/elements/attachments.js +1 -1
  29. package/dist/elements/audio-visualizer.js +1 -1
  30. package/dist/elements/autoloader.js +1 -1
  31. package/dist/elements/avatar.js +1 -1
  32. package/dist/elements/badge.js +1 -1
  33. package/dist/elements/button.js +1 -1
  34. package/dist/elements/card.js +1 -1
  35. package/dist/elements/cards.js +1 -1
  36. package/dist/elements/chain-of-thought.js +1 -1
  37. package/dist/elements/chat-scope-picker.js +1 -1
  38. package/dist/elements/chat-workspace.js +1 -1
  39. package/dist/elements/chat.js +1 -1
  40. package/dist/elements/checkpoint.js +1 -1
  41. package/dist/elements/choice.js +1 -1
  42. package/dist/elements/chunks/{Icon-b5A8hlHs.js → Icon--Y8vsFal.js} +1 -1
  43. package/dist/elements/chunks/action-icons-D9u3OzYW.js +1 -0
  44. package/dist/elements/chunks/arrow-left-CVh1TeN7.js +1 -0
  45. package/dist/elements/chunks/{artifact-BSuPYFHY.js → artifact-BkYcfIlZ.js} +1 -1
  46. package/dist/elements/chunks/attachments-DwfNlubq.js +1 -0
  47. package/dist/elements/chunks/audio-visualizer-D7lB6mvR.js +1 -0
  48. package/dist/elements/chunks/{badge-TIMiJFKc.js → badge-Q0-Cmwzz.js} +1 -1
  49. package/dist/elements/chunks/{button-B9okqG73.js → button-flUbeufF.js} +1 -1
  50. package/dist/elements/chunks/{card-renderer-CSoTL_e1.js → card-renderer-3Dyl-FpN.js} +1 -1
  51. package/dist/elements/chunks/check-B5kBPKeo.js +1 -0
  52. package/dist/elements/chunks/chevron-down-DZ1GS39h.js +1 -0
  53. package/dist/elements/chunks/chevron-right-CvP7OUd2.js +1 -0
  54. package/dist/elements/chunks/{choice-card-CGMc3kek.js → choice-card-D4QUIOWZ.js} +1 -1
  55. package/dist/elements/chunks/circle-CDf9g6MQ.js +1 -0
  56. package/dist/elements/chunks/circle-check-CA47Nqbz.js +1 -0
  57. package/dist/elements/chunks/{circle-x-mhH7pOK2.js → circle-x-CJ_HVbC8.js} +1 -1
  58. package/dist/elements/chunks/code-block-BdLiTitd.js +1 -0
  59. package/dist/elements/chunks/collapsible-BzbXpPzr.js +1 -0
  60. package/dist/elements/chunks/{composer-CZZmtYOT.js → composer-Byve-n8K.js} +2 -2
  61. package/dist/elements/chunks/{confirm-card-BWjf2-xo.js → confirm-card-B0flE3MN.js} +1 -1
  62. package/dist/elements/chunks/context-VLIL3sJ-.js +1 -0
  63. package/dist/elements/chunks/controllable-DTwTIlwp.js +1 -0
  64. package/dist/elements/chunks/conversation-item-BDztezOG.js +1 -0
  65. package/dist/elements/chunks/copy-Nf-kqxXH.js +1 -0
  66. package/dist/elements/chunks/{create-tween-CUrM6D_o.js → create-tween-c4Z8TDYz.js} +2 -2
  67. package/dist/elements/chunks/default-input-C2iLFq0b.js +1 -0
  68. package/dist/elements/chunks/define-wjHR1MkU.js +1 -0
  69. package/dist/elements/chunks/disclosure-CcNbyLy0.js +1 -0
  70. package/dist/elements/chunks/{download-DnCyWFJE.js → download-C4V7tBdb.js} +1 -1
  71. package/dist/elements/chunks/dropdown-BgWTthUx.js +1 -0
  72. package/dist/elements/chunks/{ellipsis-CVxqfsDZ.js → ellipsis-7gU33mL2.js} +1 -1
  73. package/dist/elements/chunks/embed-D3KewsIJ.js +1 -0
  74. package/dist/elements/chunks/{external-link-DG2sRdQL.js → external-link-CSOEsNlx.js} +1 -1
  75. package/dist/elements/chunks/{file-text-D0J9F6M7.js → file-text-D1G8khle.js} +1 -1
  76. package/dist/elements/chunks/{file-tree-yGWNK0Js.js → file-tree-zvB2i7P9.js} +1 -1
  77. package/dist/elements/chunks/{folder-NV1pi_ud.js → folder-WW2LGLid.js} +1 -1
  78. package/dist/elements/chunks/form-CQirdC4W.js +1 -0
  79. package/dist/elements/chunks/hover-card-CJJkfHTK.js +1 -0
  80. package/dist/elements/chunks/icon-Cfgmf4fa.js +1 -0
  81. package/dist/elements/chunks/{info-C8r5Q6cc.js → info-DX3YMOt6.js} +1 -1
  82. package/dist/elements/chunks/input-BtW9U6Sq.js +1 -0
  83. package/dist/elements/chunks/{kbd-OGJDUT-O.js → kbd-LGXwuT_V.js} +1 -1
  84. package/dist/elements/chunks/{link-BdsfLB7b.js → link-2My69BqT.js} +1 -1
  85. package/dist/elements/chunks/{link-preview-Cf5KOP-i.js → link-preview-OQ9IEq0f.js} +1 -1
  86. package/dist/elements/chunks/{loader-B5QzK_8_.js → loader-iOxc_Tu-.js} +1 -1
  87. package/dist/elements/chunks/{markdown-DbozyYqa.js → markdown-DgBFaGAz.js} +1 -1
  88. package/dist/elements/chunks/{message-DtXKPqiR.js → message-VMZohrXx.js} +1 -1
  89. package/dist/elements/chunks/message-circle-CpXMM-_n.js +1 -0
  90. package/dist/elements/chunks/message-square-DxcWtETQ.js +1 -0
  91. package/dist/elements/chunks/message-tZmV2A3p.js +1 -0
  92. package/dist/elements/chunks/{minimize-2-SegJ_oku.js → minimize-2-DPIUKTrT.js} +1 -1
  93. package/dist/elements/chunks/{model-switcher-D7JrFFHp.js → model-switcher-DsT8tFB0.js} +1 -1
  94. package/dist/elements/chunks/{overlay-CV7z2YuX.js → overlay-DuiwdXjm.js} +1 -1
  95. package/dist/elements/chunks/{paperclip-Bg0ITU3b.js → paperclip-CRZVp4z_.js} +1 -1
  96. package/dist/elements/chunks/{progress-bar-D1_rzt6N.js → progress-bar-DNy66e_E.js} +1 -1
  97. package/dist/elements/chunks/{prompt-suggestion-D5Sm8xF1.js → prompt-suggestion-Bn64Nl7L.js} +1 -1
  98. package/dist/elements/chunks/reasoning-Emn9N4-T.js +1 -0
  99. package/dist/elements/chunks/{resizable-Dpzjlvp4.js → resizable-D181b4fM.js} +1 -1
  100. package/dist/elements/chunks/{rotate-cw-DzVDJOA1.js → rotate-cw-CPWe37D9.js} +1 -1
  101. package/dist/elements/chunks/{scroll-area-93A1bbeG.js → scroll-area-ClZkZxIk.js} +1 -1
  102. package/dist/elements/chunks/scroll-button-DPE-t3x_.js +1 -0
  103. package/dist/elements/chunks/{separator-DH2r_ZSZ.js → separator-CeJHQqLW.js} +1 -1
  104. package/dist/elements/chunks/{settings-BFMOd6yB.js → settings-CqaHgJF9.js} +1 -1
  105. package/dist/elements/chunks/{settings-group-B3VZKIcz.js → settings-group-CjqjLCOn.js} +1 -1
  106. package/dist/elements/chunks/share-CyfnNEqp.js +1 -0
  107. package/dist/elements/chunks/{skeleton-N8BuWYmf.js → skeleton-D8akBnKS.js} +1 -1
  108. package/dist/elements/chunks/slots-DaNjX0rc.js +1 -0
  109. package/dist/elements/chunks/{source-w9V0NnBn.js → source-UWCvmVak.js} +1 -1
  110. package/dist/elements/chunks/{star-DHvACxtm.js → star-DoHFAikQ.js} +1 -1
  111. package/dist/elements/chunks/store-Cjq0qwYN.js +1 -0
  112. package/dist/elements/chunks/{tasks-card-lPxoodlA.js → tasks-card-CmZ5xh6v.js} +1 -1
  113. package/dist/elements/chunks/{text-shimmer-BY_rI3s5.js → text-shimmer-DYn09zrz.js} +1 -1
  114. package/dist/elements/chunks/textarea-skiGHURM.js +1 -0
  115. package/dist/elements/chunks/{thumbs-up-DQe0hvkZ.js → thumbs-up-DX2Ijk3H.js} +1 -1
  116. package/dist/elements/chunks/{toast-store-VUZZBhzH.js → toast-store-CFnQpThX.js} +1 -1
  117. package/dist/elements/chunks/{tool-C1YtxwPu.js → tool-DKQ2D1DP.js} +1 -1
  118. package/dist/elements/chunks/{tooltip-BqwnFWhn.js → tooltip-B0G99y8w.js} +1 -1
  119. package/dist/elements/chunks/{triangle-alert-B6or7F5u.js → triangle-alert-F9cyUEyy.js} +1 -1
  120. package/dist/elements/chunks/{use-card-resolution-DJg32jGH.js → use-card-resolution-DoA2f_EU.js} +1 -1
  121. package/dist/elements/chunks/{variant-aurora-QXTZPyST.js → variant-aurora-qt6Zct8M.js} +2 -2
  122. package/dist/elements/chunks/variant-custom-tJp22_BQ.js +1 -0
  123. package/dist/{variant-wave-BwzrHD1s.js → elements/chunks/variant-wave-CaQ5ti39.js} +2 -2
  124. package/dist/elements/chunks/x-B6UnOiLw.js +1 -0
  125. package/dist/elements/coachmark.js +1 -1
  126. package/dist/elements/code-block.js +1 -1
  127. package/dist/elements/command.js +1 -1
  128. package/dist/elements/compare.js +1 -1
  129. package/dist/elements/composer.js +1 -1
  130. package/dist/elements/confirm-card.js +1 -1
  131. package/dist/elements/context-meter.js +1 -1
  132. package/dist/elements/conversation-item.d.ts +1 -0
  133. package/dist/elements/conversation-item.js +1 -0
  134. package/dist/elements/conversation-list.js +1 -1
  135. package/dist/elements/default-input.d.ts +3 -3
  136. package/dist/elements/define.d.ts +27 -0
  137. package/dist/elements/diagnostic-events.d.ts +74 -0
  138. package/dist/elements/dialog.js +1 -1
  139. package/dist/elements/dock.d.ts +1 -0
  140. package/dist/elements/dock.js +149 -0
  141. package/dist/elements/dropdown.d.ts +1 -0
  142. package/dist/elements/dropdown.js +1 -0
  143. package/dist/elements/editable-label.js +1 -1
  144. package/dist/elements/element-diagnostics.d.ts +81 -0
  145. package/dist/elements/embed.js +1 -1
  146. package/dist/elements/empty.js +1 -1
  147. package/dist/elements/feedback-bar.js +1 -1
  148. package/dist/elements/file-tree.js +1 -1
  149. package/dist/elements/file-upload.js +1 -1
  150. package/dist/elements/form.js +1 -1
  151. package/dist/elements/hover-card.js +1 -1
  152. package/dist/elements/icon.js +1 -1
  153. package/dist/elements/image.js +1 -1
  154. package/dist/elements/input.js +1 -1
  155. package/dist/elements/kbd.js +1 -1
  156. package/dist/elements/link-preview.js +1 -1
  157. package/dist/elements/loader.js +1 -1
  158. package/dist/elements/markdown.js +1 -1
  159. package/dist/elements/menu.js +1 -1
  160. package/dist/elements/message-skills.js +1 -1
  161. package/dist/elements/message.js +1 -1
  162. package/dist/elements/model-switcher.js +1 -1
  163. package/dist/elements/nav.js +1 -1
  164. package/dist/elements/notice.js +1 -1
  165. package/dist/elements/pane-group.js +1 -1
  166. package/dist/elements/pane.js +1 -1
  167. package/dist/elements/popover.js +1 -1
  168. package/dist/elements/progress-bar.js +1 -1
  169. package/dist/elements/prompt-dock.js +1 -1
  170. package/dist/elements/prompt-input.js +1 -1
  171. package/dist/elements/prompt-suggestions.js +1 -1
  172. package/dist/elements/reasoning.js +1 -1
  173. package/dist/elements/register.d.ts +2 -0
  174. package/dist/elements/remote.js +1 -1
  175. package/dist/elements/resizable.js +1 -1
  176. package/dist/elements/response-stream.js +3 -3
  177. package/dist/elements/screen.js +1 -1
  178. package/dist/elements/scroll-area.js +1 -1
  179. package/dist/elements/scroll-button.js +1 -1
  180. package/dist/elements/search.js +1 -1
  181. package/dist/elements/segmented.js +1 -1
  182. package/dist/elements/separator.js +1 -1
  183. package/dist/elements/setting-item.js +1 -1
  184. package/dist/elements/settings-group.js +1 -1
  185. package/dist/elements/skeleton.js +1 -1
  186. package/dist/elements/slots.d.ts +35 -3
  187. package/dist/elements/source.js +1 -1
  188. package/dist/elements/status.js +1 -1
  189. package/dist/elements/switch.js +1 -1
  190. package/dist/elements/tabs.js +1 -1
  191. package/dist/elements/tasks.js +1 -1
  192. package/dist/elements/text-shimmer.js +1 -1
  193. package/dist/elements/thinking-bar.js +1 -1
  194. package/dist/elements/thread.js +1 -1
  195. package/dist/elements/toast.js +1 -1
  196. package/dist/elements/tool.js +1 -1
  197. package/dist/elements/tooltip.js +1 -1
  198. package/dist/elements/voice-input.js +1 -1
  199. package/dist/elements/voice-output.js +1 -1
  200. package/dist/elements.d.ts +232 -148
  201. package/dist/index.d.ts +2 -0
  202. package/dist/index.js +5456 -4931
  203. package/dist/index.server.js +4142 -3669
  204. package/dist/kai-provider.es.js +40 -40
  205. package/dist/kai.es.js +1 -1
  206. package/dist/llms/llms-full.txt +184 -69
  207. package/dist/llms/llms.txt +3 -3
  208. package/dist/mcp.es.js +2669 -546
  209. package/dist/primitives/use-resize-observer.d.ts +23 -0
  210. package/dist/primitives/use-speech-recognition.d.ts +8 -0
  211. package/dist/primitives/use-text-stream.d.ts +5 -0
  212. package/dist/react/index.d.ts +115 -266
  213. package/dist/react.js +247 -221
  214. package/dist/register-impl-DpE7icIb.js +293 -0
  215. package/dist/schemas/index.d.ts +1 -1
  216. package/dist/schemas/tool-defs.d.ts +35 -0
  217. package/dist/schemas.js +403 -310
  218. package/dist/{solid-BMuDmFo_.js → solid-BtdqNZI7.js} +8435 -7394
  219. package/dist/{solid-CGYNdLRR.js → solid-CnavwBye.js} +5987 -5035
  220. package/dist/solid.d.ts +5 -0
  221. package/dist/solid.js +210 -203
  222. package/dist/solid.server.js +210 -203
  223. package/dist/state/index.d.ts +4 -0
  224. package/dist/state/persistence.d.ts +51 -0
  225. package/dist/state/stream.d.ts +50 -0
  226. package/dist/state/threads.d.ts +71 -0
  227. package/dist/state.js +269 -169
  228. package/dist/types.d.ts +4 -2
  229. package/dist/ui/dock.d.ts +109 -0
  230. package/dist/ui/hover-card.d.ts +44 -0
  231. package/dist/{variant-aurora-DwSOfraQ.js → variant-aurora-B8II3-I1.js} +27 -27
  232. package/dist/{variant-aurora-wKvAQ5wp.js → variant-aurora-CMNWOSJR.js} +28 -28
  233. package/dist/{variant-aurora-XEkPT0_z.js → variant-aurora-CcuDMWwQ.js} +2 -2
  234. package/dist/{variant-custom-eFIg2XWQ.js → variant-custom-BdvYC7af.js} +40 -40
  235. package/dist/{variant-custom-BhS9wIpU.js → variant-custom-C714mqKl.js} +65 -65
  236. package/dist/variant-custom-L16JLLZy.js +1 -0
  237. package/dist/{elements/chunks/variant-wave-DDCwhLas.js → variant-wave-2S4La2Ka.js} +2 -2
  238. package/dist/{variant-wave-CbVsmgwT.js → variant-wave-D7ruiA1W.js} +25 -25
  239. package/dist/{variant-wave-CUWGx9Bn.js → variant-wave-ecwykeyL.js} +21 -21
  240. package/dist/wire/chunk.d.ts +47 -0
  241. package/dist/wire/diagnostics.d.ts +575 -0
  242. package/dist/wire/encode-probe.d.ts +70 -0
  243. package/dist/wire/encode.d.ts +24 -0
  244. package/dist/wire/index.d.ts +2 -0
  245. package/dist/wire/sse.d.ts +10 -2
  246. package/dist/wire.js +917 -480
  247. package/frameworks/react/index.tsx +132 -93
  248. package/llms-full.txt +184 -69
  249. package/llms.txt +3 -3
  250. package/package.json +27 -7
  251. package/src/agent-tooling/archetypes.ts +41 -9
  252. package/src/agent-tooling/catalog/README.md +676 -0
  253. package/src/agent-tooling/catalog/catalog-types.ts +231 -0
  254. package/src/agent-tooling/catalog/fabrications.ts +96 -0
  255. package/src/agent-tooling/catalog/invariants.ts +284 -0
  256. package/src/agent-tooling/catalog/labs-titles.ts +114 -0
  257. package/src/agent-tooling/catalog/scenarios.ts +87 -0
  258. package/src/agent-tooling/catalog/surfaces.ts +275 -0
  259. package/src/agent-tooling/integrations/anthropic.ts +7 -1
  260. package/src/agent-tooling/integrations/cloudflare.ts +43 -2
  261. package/src/agent-tooling/integrations/langgraph.ts +7 -1
  262. package/src/agent-tooling/integrations/mastra.ts +7 -1
  263. package/src/agent-tooling/integrations/mock.ts +92 -17
  264. package/src/agent-tooling/integrations/ollama.ts +7 -1
  265. package/src/agent-tooling/integrations/openai.ts +7 -1
  266. package/src/agent-tooling/integrations/openrouter.ts +7 -1
  267. package/src/agent-tooling/integrations/pi.ts +56 -1
  268. package/src/agent-tooling/integrations/vercel-ai-sdk.ts +7 -1
  269. package/src/agent-tooling/mcp/css-raw.d.ts +12 -0
  270. package/src/agent-tooling/mcp/manifest.ts +100 -1
  271. package/src/agent-tooling/mcp/server.ts +61 -5
  272. package/src/agent-tooling/mcp/tools/reference.ts +440 -4
  273. package/src/agent-tooling/mcp/tools/scaffold.ts +1834 -307
  274. package/src/agent-tooling/mcp/tools/theme.ts +146 -30
  275. package/src/agent-tooling/mcp/validate-args.ts +141 -0
  276. package/src/agent-tooling/registry.ts +19 -7
  277. package/src/agent-tooling/route-emit.ts +26 -2
  278. package/src/components/attachment-types.ts +47 -1
  279. package/src/components/attachments.tsx +169 -22
  280. package/src/components/audio-visualizer/fit-scale.ts +112 -0
  281. package/src/components/audio-visualizer/index.tsx +80 -3
  282. package/src/components/audio-visualizer/variant-aurora.tsx +10 -4
  283. package/src/components/audio-visualizer/variant-bar.tsx +38 -6
  284. package/src/components/audio-visualizer/variant-custom.tsx +8 -2
  285. package/src/components/audio-visualizer/variant-grid.tsx +16 -11
  286. package/src/components/audio-visualizer/variant-radial.tsx +15 -10
  287. package/src/components/audio-visualizer/variant-wave.tsx +7 -2
  288. package/src/components/chat-container.tsx +4 -1
  289. package/src/components/chat-thread.tsx +36 -7
  290. package/src/components/code-block.tsx +79 -3
  291. package/src/components/composer.tsx +8 -1
  292. package/src/components/conversation-item.tsx +114 -4
  293. package/src/components/conversation-list.tsx +212 -12
  294. package/src/components/embed.tsx +4 -0
  295. package/src/components/form.tsx +41 -14
  296. package/src/components/message.tsx +75 -5
  297. package/src/components/response-stream.tsx +13 -7
  298. package/src/components/screen.tsx +5 -0
  299. package/src/components/scroll-button.tsx +7 -0
  300. package/src/components/toast.tsx +11 -5
  301. package/src/components/voice-input.tsx +35 -3
  302. package/src/components/voice-output.tsx +46 -6
  303. package/src/components/workspace-shell.tsx +285 -0
  304. package/src/diagnostics/hook.ts +352 -0
  305. package/src/diagnostics/index.ts +82 -0
  306. package/src/diagnostics/report-request.ts +296 -0
  307. package/src/elements/attachments.tsx +6 -7
  308. package/src/elements/audio-visualizer.tsx +48 -4
  309. package/src/elements/chat-workspace.tsx +159 -287
  310. package/src/elements/chat.tsx +12 -9
  311. package/src/elements/code-block.tsx +19 -1
  312. package/src/elements/command.tsx +4 -1
  313. package/src/elements/compiled.css +1 -1
  314. package/src/elements/conversation-item.tsx +80 -0
  315. package/src/elements/conversation-list.tsx +60 -11
  316. package/src/elements/default-input.tsx +5 -5
  317. package/src/elements/define.tsx +168 -8
  318. package/src/elements/diagnostic-events.ts +114 -0
  319. package/src/elements/dialog.tsx +47 -0
  320. package/src/elements/disclosure.ts +34 -12
  321. package/src/elements/dock.tsx +204 -0
  322. package/src/elements/dropdown.tsx +123 -0
  323. package/src/elements/element-diagnostics.ts +392 -0
  324. package/src/elements/element-manifest.json +12 -0
  325. package/src/elements/element-meta.json +672 -439
  326. package/src/elements/element-nonscalar.json +170 -0
  327. package/src/elements/element-types.d.ts +232 -148
  328. package/src/elements/menu.tsx +13 -0
  329. package/src/elements/prompt-dock.tsx +2 -1
  330. package/src/elements/prompt-input.tsx +8 -8
  331. package/src/elements/register-impl.ts +46 -0
  332. package/src/elements/register.ts +28 -0
  333. package/src/elements/resizable.tsx +52 -25
  334. package/src/elements/scroll-button.tsx +5 -0
  335. package/src/elements/slots.ts +107 -12
  336. package/src/elements/styles.css +30 -6
  337. package/src/elements/thinking-bar.tsx +16 -7
  338. package/src/elements/toast.tsx +3 -0
  339. package/src/elements/tool.tsx +26 -5
  340. package/src/elements/voice-input.tsx +11 -2
  341. package/src/elements/voice-output.tsx +16 -3
  342. package/src/index.ts +2 -0
  343. package/src/primitives/use-resize-observer.ts +33 -0
  344. package/src/primitives/use-speech-recognition.ts +12 -1
  345. package/src/primitives/use-stick-to-bottom.ts +34 -2
  346. package/src/primitives/use-text-stream.ts +33 -2
  347. package/src/remote/provider-runtime.ts +6 -3
  348. package/src/schemas/index.ts +1 -0
  349. package/src/schemas/tool-defs.ts +297 -6
  350. package/src/solid.ts +5 -0
  351. package/src/state/index.ts +12 -0
  352. package/src/state/persistence.ts +180 -0
  353. package/src/state/stream.ts +71 -4
  354. package/src/state/threads.ts +142 -0
  355. package/src/types.ts +4 -2
  356. package/src/ui/dialog.tsx +6 -0
  357. package/src/ui/dock.tsx +445 -0
  358. package/src/ui/dropdown.tsx +67 -23
  359. package/src/ui/hover-card.tsx +156 -6
  360. package/src/ui/input.tsx +29 -10
  361. package/src/ui/resizable.tsx +11 -0
  362. package/src/wire/chunk.ts +47 -0
  363. package/src/wire/consume.ts +185 -26
  364. package/src/wire/diagnostics.ts +727 -0
  365. package/src/wire/encode-probe.ts +214 -0
  366. package/src/wire/encode.ts +267 -14
  367. package/src/wire/formats/anthropic.ts +8 -1
  368. package/src/wire/formats/openai.ts +5 -0
  369. package/src/wire/index.ts +22 -0
  370. package/src/wire/read.ts +250 -6
  371. package/src/wire/sse.ts +16 -3
  372. package/dist/elements/chunks/action-icons-DW9muWrY.js +0 -1
  373. package/dist/elements/chunks/arrow-left-iJVnQmxA.js +0 -1
  374. package/dist/elements/chunks/attachments-CL6TaqIb.js +0 -1
  375. package/dist/elements/chunks/audio-visualizer-BuUqQmaO.js +0 -1
  376. package/dist/elements/chunks/chat-thread-DxoKaStk.js +0 -1
  377. package/dist/elements/chunks/check-MdLzbxrm.js +0 -1
  378. package/dist/elements/chunks/chevron-down-MK7IzDqe.js +0 -1
  379. package/dist/elements/chunks/chevron-right-C5jXhmtV.js +0 -1
  380. package/dist/elements/chunks/circle-BG7VkPep.js +0 -1
  381. package/dist/elements/chunks/circle-check-GqvO2UBs.js +0 -1
  382. package/dist/elements/chunks/code-block-ClhQO9RW.js +0 -1
  383. package/dist/elements/chunks/collapsible-DBcBkFPh.js +0 -1
  384. package/dist/elements/chunks/context-hKjNqiyQ.js +0 -1
  385. package/dist/elements/chunks/conversation-list-CsGJjSKG.js +0 -1
  386. package/dist/elements/chunks/default-input-B1Mem5wu.js +0 -1
  387. package/dist/elements/chunks/define-B_tXMYRm.js +0 -1
  388. package/dist/elements/chunks/disclosure-DAoYOKew.js +0 -1
  389. package/dist/elements/chunks/dropdown-CmNna5lT.js +0 -1
  390. package/dist/elements/chunks/embed-CjAD2jSi.js +0 -1
  391. package/dist/elements/chunks/form-Cp8Y00Wn.js +0 -1
  392. package/dist/elements/chunks/hover-card-6KtXpLwT.js +0 -1
  393. package/dist/elements/chunks/icon-97R8SR2p.js +0 -1
  394. package/dist/elements/chunks/input-DrseEjhp.js +0 -1
  395. package/dist/elements/chunks/message-CE4k-qaj.js +0 -1
  396. package/dist/elements/chunks/message-square-C9ovmtOo.js +0 -1
  397. package/dist/elements/chunks/reasoning-BrxQfpfI.js +0 -1
  398. package/dist/elements/chunks/scroll-button-CrTo7wm4.js +0 -1
  399. package/dist/elements/chunks/share-DgNbkJKY.js +0 -1
  400. package/dist/elements/chunks/slots-CIw9RlAe.js +0 -1
  401. package/dist/elements/chunks/store-HIPGhNz9.js +0 -1
  402. package/dist/elements/chunks/textarea-DPfuhswM.js +0 -1
  403. package/dist/elements/chunks/variant-custom-D5ZlmziF.js +0 -1
  404. package/dist/elements/chunks/video-Bb86fVYI.js +0 -1
  405. package/dist/elements/chunks/x-BOhjBeOd.js +0 -1
  406. package/dist/primitives/card-validate-generator.testlib.d.ts +0 -20
  407. package/dist/register-impl-DGmGzrEM.js +0 -145
  408. package/dist/variant-custom-MkTNTycd.js +0 -1
  409. package/src/primitives/card-validate-generator.testlib.ts +0 -38
@@ -21,6 +21,12 @@ import { chatRoutePreamble, defaultModelFor } from '../../route-emit';
21
21
  // so the Node MCP pass typechecks and bundles it unchanged. See
22
22
  // `ATTACHMENT_ACCEPT`.
23
23
  import { encodableMediaTypes } from '../../../wire/media-types';
24
+ // The composition catalog, read for one fact: where a companion element GOES.
25
+ // The RAW literal rather than `listSurfaceRecipes()`, matching `reference.ts` —
26
+ // the accessor re-runs a zod parse over the whole catalog on every call, and
27
+ // these records are already validated by their own tests and by
28
+ // `lint:catalog-drift`.
29
+ import { surfaceRecipes } from '../../catalog/surfaces';
24
30
 
25
31
  /**
26
32
  * scaffold — the keystone tool. Composes a working chat surface from four axes:
@@ -101,21 +107,34 @@ const FLEX_FILL = 'flex: 1; min-height: 0;';
101
107
  * stops consuming height. `text-align: start` is still needed because text-align
102
108
  * INHERITS regardless of positioning.
103
109
  *
104
- * `z-index` matters as much as the positioning, and is the same 1000 the other two
105
- * fixed placements already use. Without it the surface stacks at `auto`, and the
106
- * TanStack starter's header is `sticky top-0 z-50` so the chat would sit at the
107
- * right geometry and still have the top 73px of its thread painted over. Half-
108
- * covered is the worst outcome available: either the chat owns the viewport or it
109
- * does not.
110
+ * `z-index` matters as much as the positioning. Without it the surface stacks at
111
+ * `auto`, and the TanStack starter's header is `sticky top-0 z-50` so the chat
112
+ * would sit at the right geometry and still have the top 73px of its thread
113
+ * painted over. Half-covered is the worst outcome available: either the chat owns
114
+ * the viewport or it does not.
115
+ *
116
+ * WHY 90 AND NOT 1000 (F-20's scaffold-side sibling). The kit's `kai-toast-region`
117
+ * — which `toast()` mounts as a body-level SIBLING of the app root — paints its
118
+ * stack at `z-index: var(--kai-toast-z, 100)`, and both layers live in the root
119
+ * stacking context. Every fixed placement here used to stamp `z-index: 1000`, so
120
+ * every scaffolded app buried every toast (Undo included) under its own wrapper —
121
+ * the exact defect the corpus workspace app's IVP caught in its builder CSS.
122
+ * 90 still outranks any sticky starter chrome (z-50 is the highest observed) and
123
+ * stays below the toast layer; the emitted comment states the contract so a
124
+ * consumer raising the wrapper knows what they are burying.
110
125
  *
111
126
  * The trade is stated in the emitted comment rather than hidden: this overlays
112
127
  * whatever the starter draws around it, nav included. A consumer who wants the chat
113
128
  * inside their layout wants `placement: 'inline'`, which is what that placement is for.
114
129
  */
130
+ const TOAST_LAYER_NOTE =
131
+ 'The z-index stays BELOW 100: the kit\'s kai-toast-region (a body-level sibling toast() mounts) ' +
132
+ 'paints at z-index var(--kai-toast-z, 100), so a wrapper at or above it buries every toast.';
133
+
115
134
  const FULL_PAGE: PlacementStyle = {
116
135
  style:
117
136
  'position: fixed; inset: 0; display: flex; flex-direction: column; ' +
118
- 'text-align: start; z-index: 1000;',
137
+ 'text-align: start; z-index: 90;',
119
138
  chatFill: FLEX_FILL,
120
139
  note: 'fills the viewport (fixed, inset 0)',
121
140
  altNote: [
@@ -125,6 +144,7 @@ const FULL_PAGE: PlacementStyle = {
125
144
  'TanStack Start starter wraps every route in a Header + Footer that pushed the composer 13px',
126
145
  'below the fold at 1280x800. Fixed positioning escapes both, `text-align: start` undoes the',
127
146
  'inherited centring, and z-index keeps a sticky header from painting over the thread.',
147
+ TOAST_LAYER_NOTE,
128
148
  'This DOES cover the chrome around it (nav included) — that is what full-page means here.',
129
149
  'Want the chat to sit INSIDE your own layout instead? Use placement: "inline".',
130
150
  ],
@@ -146,23 +166,27 @@ function placementStyle(placement: string): PlacementStyle {
146
166
  return {
147
167
  style:
148
168
  'position: fixed; top: 0; inset-inline-end: 0; height: 100dvh; width: 380px; ' +
149
- 'border-inline-start: 1px solid var(--kai-color-border); display: flex; flex-direction: column; z-index: 1000;',
169
+ 'border-inline-start: 1px solid var(--kai-color-border); display: flex; flex-direction: column; z-index: 90;',
150
170
  chatFill: FLEX_FILL,
151
171
  note: 'full-height side panel, docked to the trailing edge (100dvh)',
152
172
  altNote: [
173
+ TOAST_LAYER_NOTE,
153
174
  'In-flow alternative (push content instead of overlay): drop `position`/`z-index` and ' +
154
175
  'make this a `flex: 0 0 380px` column inside a `display: flex` row at `height: 100dvh`.',
155
176
  ],
156
177
  };
157
178
  case 'docked-widget':
158
- // The bottom-right floating bubble — rounded, elevated, fixed size.
179
+ // The bottom-right floating bubble — rounded, elevated, fixed size. Its
180
+ // corner is exactly where the toast stack lives, so the z-index contract
181
+ // matters MOST here.
159
182
  return {
160
183
  style:
161
184
  'position: fixed; bottom: 1.5rem; inset-inline-end: 1.5rem; width: 380px; height: 600px; ' +
162
185
  'max-height: calc(100dvh - 3rem); border-radius: 16px; overflow: hidden; ' +
163
- 'box-shadow: 0 12px 32px var(--kai-shadow-color, rgba(0,0,0,0.18)); display: flex; flex-direction: column; z-index: 1000;',
186
+ 'box-shadow: 0 12px 32px var(--kai-shadow-color, rgba(0,0,0,0.18)); display: flex; flex-direction: column; z-index: 90;',
164
187
  chatFill: FLEX_FILL,
165
188
  note: 'fixed, floating bottom-right widget',
189
+ altNote: [TOAST_LAYER_NOTE],
166
190
  };
167
191
  default:
168
192
  // Unknown placement falls back to full-page (full height) rather than the bubble,
@@ -323,6 +347,17 @@ function realStreamBody(opts: {
323
347
  valueSource?: string;
324
348
  /** lines emitted right after the value is read and guarded */
325
349
  afterValue?: string[];
350
+ /**
351
+ * Lines emitted in the `finally` block, after the turn has settled.
352
+ *
353
+ * `finally` rather than after the `try`, and that is the whole reason this hook
354
+ * exists rather than each renderer appending its own lines: anything that keeps a
355
+ * SECOND view of the thread in step with it (a conversation row's count, its
356
+ * title, its timestamps) has to run on the error path too, or a failed turn
357
+ * leaves the two views disagreeing — the user's own message is on the thread
358
+ * whether or not the model answered.
359
+ */
360
+ afterTurn?: string[];
326
361
  /**
327
362
  * The attachments capability: an EXPRESSION producing this turn's staged
328
363
  * attachments, which become `file` parts ahead of the message's text part.
@@ -343,10 +378,19 @@ function realStreamBody(opts: {
343
378
  * identical. That identity is the point: see `mockRequest`.
344
379
  */
345
380
  mock?: boolean;
381
+ /**
382
+ * The workspace block's cancellation story: an expression for the turn's
383
+ * AbortSignal, put on the fetch. The controller comes from
384
+ * `createThreadSessions().begin(id)` in the block's prelude, so switching
385
+ * threads or deleting one can abort the outgoing request rather than only
386
+ * dropping its deltas. Ignored on the mock path (there is no request).
387
+ */
388
+ fetchSignal?: string;
346
389
  }): string {
347
390
  const {
348
391
  pad, read, commitSet, setterAdapter, setLoading, bodyPayload, strictRoles = false, toolLoop, thread,
349
- cards = false, valueSource = 'e.detail.value', afterValue = [], mock = false, filesExpr,
392
+ cards = false, valueSource = 'e.detail.value', afterValue = [], afterTurn = [], mock = false, filesExpr,
393
+ fetchSignal,
350
394
  } = opts;
351
395
  const asConst = strictRoles ? ' as const' : '';
352
396
  // Under strict TS an un-annotated array literal widens the part's `type` to
@@ -409,6 +453,13 @@ function realStreamBody(opts: {
409
453
  `${indent} method: 'POST',`,
410
454
  `${indent} headers: { 'Content-Type': 'application/json' },`,
411
455
  `${indent} body: JSON.stringify(${bodyPayload(threadExpr)}),`,
456
+ ...(fetchSignal
457
+ ? [
458
+ `${indent} // The turn's own AbortSignal (from sessions.begin above): switching`,
459
+ `${indent} // threads or deleting this one can cancel the request itself.`,
460
+ `${indent} signal: ${fetchSignal},`,
461
+ ]
462
+ : []),
412
463
  `${indent}});`,
413
464
  `${indent}// The finished turn: text, reasoning, tool calls, stop reason, usage. An`,
414
465
  `${indent}// error FRAME inside a 200 stream lands on turn.error, and whatever`,
@@ -435,17 +486,31 @@ function realStreamBody(opts: {
435
486
  `${pad} if (turn.error) console.error('Model error:', turn.error.message);`,
436
487
  ]),
437
488
  `${pad}} catch (err) {`,
489
+ ...(fetchSignal && !mock
490
+ ? [
491
+ `${pad} // A DELIBERATE abort (the thread was deleted, or a new turn began on it)`,
492
+ `${pad} // is not a failure and has no one left to report it to.`,
493
+ `${pad} if (${fetchSignal}.aborted) return;`,
494
+ ]
495
+ : []),
438
496
  `${pad} // Without this a bad key is a permanently blank assistant bubble plus an`,
439
- `${pad} // unhandled rejection. abort() settles the message and flips any tool`,
440
- `${pad} // panel still waiting on a result to output-error, so nothing spins`,
441
- `${pad} // forever; text that already streamed stays put.`,
442
- `${pad} stream.abort(err instanceof Error ? err.message : 'Request failed');`,
443
- `${pad} console.error(err); // swap in your own error surface (a toast, a banner)`,
497
+ `${pad} // unhandled rejection. abort(reason) settles the message AND puts the`,
498
+ `${pad} // reason where the reader can see it: onto any tool panel still waiting`,
499
+ `${pad} // on a result (output-error, so nothing spins forever), or — on a`,
500
+ `${pad} // text-only turn, which has no panel to carry it — as a new text part in`,
501
+ `${pad} // the thread. Text that already streamed stays put.`,
502
+ `${pad} // The reason is SHOWN TO THE USER. err.message is the honest default;`,
503
+ `${pad} // swap in a sentence a visitor can read once you know your failure modes.`,
504
+ `${pad} // The && is load-bearing: an Error is free to carry an empty message, and`,
505
+ `${pad} // an empty reason is nothing to show — back to the blank bubble.`,
506
+ `${pad} stream.abort(err instanceof Error && err.message ? err.message : 'Request failed');`,
507
+ `${pad} console.error(err); // the console is for you; the thread is for them`,
444
508
  `${pad}} finally {`,
445
509
  `${pad} // done() SETTLES the message: every sink call after it is dropped, which`,
446
510
  `${pad} // is why the whole loop runs above it and not after.`,
447
511
  `${pad} stream.done();`,
448
512
  `${pad} ${setLoading('false')}`,
513
+ ...afterTurn.map((l) => `${pad} ${l}`),
449
514
  `${pad}}`,
450
515
  ].join('\n');
451
516
  }
@@ -742,13 +807,17 @@ function wireImportLines(opts: {
742
807
  * needs this one from the state entry.
743
808
  */
744
809
  attachments?: boolean;
810
+ /** the workspace BLOCK → the thread/persistence helpers from the state entry */
811
+ workspaceBlock?: boolean;
745
812
  }): string[] {
746
- const { pad = '', typed, toolLoop = false, setMessagesType = false, cards = false, cardTools: emitsCardTools = false, mock = false, attachments = false } = opts;
813
+ const { pad = '', typed, toolLoop = false, setMessagesType = false, cards = false, cardTools: emitsCardTools = false, mock = false, attachments = false, workspaceBlock = false } = opts;
747
814
  const stateNames = [
815
+ ...(workspaceBlock ? ['bindThreadMessages'] : []),
748
816
  'createAssistantStream',
749
817
  // The mock's canned reply comes from the kit, not from a copy pasted into
750
818
  // this file. One implementation, shared with create-kai and the starters.
751
819
  ...(mock ? ['createMockResponder'] : []),
820
+ ...(workspaceBlock ? ['createSaveScheduler', 'createThreadSessions', 'parseStoredThread'] : []),
752
821
  ...(attachments ? ['type AttachmentData'] : []),
753
822
  ...(typed ? ['type ChatMessage'] : []),
754
823
  ...(typed && setMessagesType ? ['type SetMessages'] : []),
@@ -983,6 +1052,13 @@ function jsxComment(lines: readonly string[], pad: string): string[] {
983
1052
  return lines.map((l) => `${pad}{/* ${l.replace(/^\/\/ ?/, '')} */}`);
984
1053
  }
985
1054
 
1055
+ /** Re-indent already-built emitted lines (some entries hold multiple lines
1056
+ * joined with \n) so a wrapper — the block's shell — can nest them without
1057
+ * every builder above knowing about the wrapping. Blank lines stay blank. */
1058
+ function indentEmitted(lines: readonly string[], by: string): string[] {
1059
+ return lines.flatMap((l) => l.split('\n')).map((l) => (l === '' ? l : `${by}${l}`));
1060
+ }
1061
+
986
1062
  // ── SCAF-9: message-embedded companion logic ──────────────────────────────────
987
1063
 
988
1064
  /**
@@ -992,21 +1068,380 @@ function jsxComment(lines: readonly string[], pad: string): string[] {
992
1068
  */
993
1069
  const MESSAGE_EMBEDDED_TAGS = new Set(['kai-tool', 'kai-reasoning']);
994
1070
 
995
- // ── SCAF-14: workspace structural/layout logic ────────────────────────────────
1071
+ // ── SCAF-14: artifact-split structural/layout logic ───────────────────────────
996
1072
 
997
1073
  /**
998
- * Tags that participate in the workspace layout structure — kai-resizable is the
999
- * container (needs kai-resizable-item children), kai-artifact is the preview pane.
1000
- * Neither should be emitted as a bare sibling of kai-chat — the idiomatic structure
1001
- * is a resizable split with chat in one pane and artifact in another.
1074
+ * Tags that participate in the artifact-split layout structure — kai-resizable
1075
+ * is the container (needs kai-resizable-item children), kai-artifact is the
1076
+ * preview pane. Neither should be emitted as a bare sibling of kai-chat — the
1077
+ * idiomatic structure is a resizable split with chat in one pane and artifact
1078
+ * in another.
1079
+ *
1080
+ * (This predicate was named `isWorkspace` until the recast: the `workspace`
1081
+ * preset now names the shell + rail BLOCK below, and a predicate about the
1082
+ * artifact pair claiming that name would lie about both.)
1002
1083
  */
1003
- const WORKSPACE_STRUCTURAL_TAGS = new Set(['kai-resizable', 'kai-artifact']);
1084
+ const ARTIFACT_SPLIT_TAGS = new Set(['kai-resizable', 'kai-artifact']);
1004
1085
 
1005
- /** True when the surface is the resizable split workspace (chat + artifact). */
1006
- function isWorkspace(components: readonly string[]): boolean {
1086
+ /** True when the surface renders the resizable split (chat + artifact preview). */
1087
+ function isArtifactSplit(components: readonly string[]): boolean {
1007
1088
  return components.includes('kai-resizable') && components.includes('kai-artifact');
1008
1089
  }
1009
1090
 
1091
+ // ── the workspace BLOCK (recast spec 2026-08-20 § 3b; F-16) ──────────────────
1092
+
1093
+ /**
1094
+ * Tags the block composes structurally: the shell wraps the surface and the
1095
+ * rail sits in its `start` slot, so neither is ever a bare sibling.
1096
+ */
1097
+ const BLOCK_TAGS = new Set(['kai-workspace', 'kai-conversations']);
1098
+
1099
+ /**
1100
+ * True when the surface is the workspace BLOCK: the `kai-workspace` layout
1101
+ * shell with a wired `kai-conversations` rail in its start aside, `kai-chat`
1102
+ * in the main region, and multi-thread state built on the `@kitn.ai/ui/state`
1103
+ * helpers (bindThreadMessages / createThreadSessions / createSaveScheduler /
1104
+ * parseStoredThread). BOTH tags, for the same reason `isArtifactSplit` needs
1105
+ * both: a shell with nothing in its aside is an empty column, and a rail with
1106
+ * no thread store behind it is the bare-sibling defect the rail wiring exists
1107
+ * to prevent. They are one capability group in `archetypes.ts`, so in practice
1108
+ * a request carries both or neither.
1109
+ *
1110
+ * The block is CODE THE CONSUMER OWNS from generation time (spec § Taxonomy):
1111
+ * the persistence policy, the fetch line and the record shape are exactly what
1112
+ * a consumer should be editing without asking us. The kit-owned mechanics ride
1113
+ * in the npm-updateable helpers instead, which is the split the emitted
1114
+ * comments below spell out.
1115
+ */
1116
+ function isWorkspaceBlock(components: readonly string[]): boolean {
1117
+ return components.includes('kai-workspace') && components.includes('kai-conversations');
1118
+ }
1119
+
1120
+ /**
1121
+ * The block's module-scope thread store: the record shape the APP owns, plus
1122
+ * load/save with the persistence POLICY stated as the consumer's. Framework-
1123
+ * independent by construction — every target below emits these lines verbatim
1124
+ * (module scope in all of them), so the boundary comment cannot drift between
1125
+ * frameworks.
1126
+ *
1127
+ * `parseStoredThread` is the kit-owned half: it validates the stored messages
1128
+ * back into `ChatMessage[]` with the variant list DERIVED from the MessagePart
1129
+ * union, and REPORTS what it dropped. The verb — warn, discard the record,
1130
+ * telemetry — stays out here, in the consumer's line.
1131
+ */
1132
+ function blockThreadStoreLines(pad = ''): string[] {
1133
+ return [
1134
+ `${pad}// ── The workspace block's thread store — YOUR code, from here down ─────────`,
1135
+ `${pad}//`,
1136
+ `${pad}// The record shape is yours (title policy, timestamps, whatever you add);`,
1137
+ `${pad}// the kit's helpers only require { id, messages } (ThreadLike).`,
1138
+ `${pad}interface Thread {`,
1139
+ `${pad} id: string;`,
1140
+ `${pad} title: string;`,
1141
+ `${pad} updatedAt: string;`,
1142
+ `${pad} messages: ChatMessage[];`,
1143
+ `${pad}}`,
1144
+ ``,
1145
+ `${pad}const THREADS_KEY = 'kai-workspace/threads/v1';`,
1146
+ ``,
1147
+ `${pad}// PERSISTENCE IS YOURS. localStorage here is a starting policy, not kit`,
1148
+ `${pad}// behavior: the backend, the quota, what is retained and for how long are`,
1149
+ `${pad}// application decisions. Swap these two functions for your API and nothing`,
1150
+ `${pad}// else in the block changes.`,
1151
+ `${pad}function loadThreads(): Thread[] {`,
1152
+ `${pad} let raw: string | null = null;`,
1153
+ `${pad} // Private-mode Safari throws on ACCESS: no storage is a working app with`,
1154
+ `${pad} // no history, not a crash on first paint.`,
1155
+ `${pad} try { raw = localStorage.getItem(THREADS_KEY); } catch { return []; }`,
1156
+ `${pad} if (!raw) return [];`,
1157
+ `${pad} try {`,
1158
+ `${pad} const stored: unknown = JSON.parse(raw);`,
1159
+ `${pad} if (!Array.isArray(stored)) return [];`,
1160
+ `${pad} return stored.flatMap((record): Thread[] => {`,
1161
+ `${pad} if (typeof record !== 'object' || record === null) return [];`,
1162
+ `${pad} const r = record as { id?: unknown; title?: unknown; updatedAt?: unknown; messages?: unknown };`,
1163
+ `${pad} if (typeof r.id !== 'string' || r.id === '') return [];`,
1164
+ `${pad} // The kit validates the MESSAGES (the shape it owns — the MessagePart`,
1165
+ `${pad} // variant list is derived, not hand-typed) and reports the drops;`,
1166
+ `${pad} // deciding what a drop means is this line, and it is yours.`,
1167
+ `${pad} const { messages, dropped } = parseStoredThread(r.messages);`,
1168
+ `${pad} if (dropped.length > 0) console.warn('[threads] dropped unreadable stored entries', dropped);`,
1169
+ `${pad} return [{`,
1170
+ `${pad} id: r.id,`,
1171
+ `${pad} title: typeof r.title === 'string' ? r.title : 'New conversation',`,
1172
+ `${pad} updatedAt: typeof r.updatedAt === 'string' ? r.updatedAt : new Date().toISOString(),`,
1173
+ `${pad} messages,`,
1174
+ `${pad} }];`,
1175
+ `${pad} });`,
1176
+ `${pad} } catch {`,
1177
+ `${pad} // Unparseable history is ignored, loudly enough to notice in dev.`,
1178
+ `${pad} console.warn('[threads] stored history could not be parsed and was ignored');`,
1179
+ `${pad} return [];`,
1180
+ `${pad} }`,
1181
+ `${pad}}`,
1182
+ ``,
1183
+ `${pad}function saveThreads(threads: Thread[]): void {`,
1184
+ `${pad} // QuotaExceededError is the realistic failure. Warn-and-continue is a`,
1185
+ `${pad} // POLICY (yours): the alternative — throwing out of a save mid-stream —`,
1186
+ `${pad} // would take the thread down with it.`,
1187
+ `${pad} try { localStorage.setItem(THREADS_KEY, JSON.stringify(threads)); }`,
1188
+ `${pad} catch (err) { console.warn('[threads] history could not be saved', err); }`,
1189
+ `${pad}}`,
1190
+ ``,
1191
+ `${pad}// First line of the first user turn, clipped — the fallback titling policy.`,
1192
+ `${pad}// Titles are what the rail's built-in search matches, so this is also the`,
1193
+ `${pad}// search index; swap in your own (a summariser, a user rename).`,
1194
+ `${pad}function deriveTitle(text: string): string {`,
1195
+ `${pad} const firstLine = text.trim().split('\\n', 1)[0] ?? '';`,
1196
+ `${pad} if (firstLine === '') return 'New conversation';`,
1197
+ `${pad} return firstLine.length > 48 ? firstLine.slice(0, 47) + '…' : firstLine;`,
1198
+ `${pad}}`,
1199
+ ];
1200
+ }
1201
+
1202
+ /**
1203
+ * The rail's comment, one wording for every target (the html-comment targets
1204
+ * wrap it in `htmlComment`, the JSX targets in `jsxComment`): what the rows
1205
+ * are, and the item-mode escape hatch by name.
1206
+ */
1207
+ const BLOCK_RAIL_NOTE = [
1208
+ `The rail renders the ROWS it is handed (batteries mode) — a view of your`,
1209
+ `records, no message bodies. To own the row markup instead, slot your own`,
1210
+ `<kai-conversation-item> loop as light-DOM children (item mode): data rows`,
1211
+ `stop rendering while any item child is present, and selection still arrives`,
1212
+ `as kai-conversation-select.`,
1213
+ ];
1214
+
1215
+ /** The same note where the rows are the Solid `ConversationList` (which takes
1216
+ * `items` instead of light-DOM children). Mirrored wording, Solid names. */
1217
+ const BLOCK_RAIL_NOTE_SOLID = [
1218
+ `The rail renders the ROWS it is handed (batteries mode) — a view of your`,
1219
+ `records, no message bodies. To own the row markup instead, pass your own`,
1220
+ `SlottedConversationItem loop via the \`items\` prop (item mode): data rows`,
1221
+ `stop rendering while items are present, and selection still arrives through`,
1222
+ `onSelect.`,
1223
+ ];
1224
+
1225
+ /**
1226
+ * The submit prelude's comments, shared so the wording tests pin ONE copy.
1227
+ * Each target interleaves these with its own state idiom's lines.
1228
+ */
1229
+ const BLOCK_TARGET_ID_NOTE = [
1230
+ `The thread that was open when the user hit send — not whichever is open`,
1231
+ `when the tokens land. Everything below binds to this id, which is what`,
1232
+ `makes switching away mid-reply safe.`,
1233
+ ];
1234
+ const BLOCK_BIND_NOTE = [
1235
+ `bindThreadMessages routes every stream delta to THIS thread, with the`,
1236
+ `reactivity two-halves (new array + new object) handled by the helper.`,
1237
+ `\`touch\` is your policy hook — here it stamps updatedAt on every delta.`,
1238
+ ];
1239
+ const BLOCK_SESSIONS_NOTE = [
1240
+ `One in-flight turn per thread: begin() aborts a prior turn on the same`,
1241
+ `thread and hands back its AbortController. Deleting a thread mid-stream is`,
1242
+ `sessions.abort(id) — the aborted turn's late deltas are dropped by the`,
1243
+ `bound sink instead of resurrecting the record.`,
1244
+ ];
1245
+
1246
+ /**
1247
+ * The block's tool-loop thread binding for every target whose store reads back
1248
+ * synchronously (signals, refs, runes, a plain module `let`): the STORE is the
1249
+ * thread. The id-bound sink writes each delta into the record, and each loop
1250
+ * round re-encodes the live value read back out — no turn-local copy to keep
1251
+ * in sync. React is the exception (async state) and keeps its own copy binding.
1252
+ */
1253
+ function blockLiveThread(readExpr: string): ThreadBinding {
1254
+ return {
1255
+ open: ({ pad, userMessage }) => [
1256
+ `${pad}// The STORE is the thread: the id-bound sink writes each delta back into`,
1257
+ `${pad}// the record, so every round below re-encodes the live, current value.`,
1258
+ `${pad}setMessages(() => [...${readExpr}, ${userMessage}]);`,
1259
+ ],
1260
+ live: readExpr,
1261
+ setter: 'setMessages',
1262
+ };
1263
+ }
1264
+
1265
+ // ── composition: where a companion GOES ──────────────────────────────────────
1266
+
1267
+ /**
1268
+ * The catalog's nesting decision for one tag, or null.
1269
+ *
1270
+ * READ from the surface recipes rather than typed here, so the slot name and the
1271
+ * parent live in exactly one place. Before this, the scaffolder emitted the
1272
+ * conversation rail as a sibling BELOW the chat in a `flex-direction: column`,
1273
+ * while `<kai-chat>` documented a `sidebar` slot described as "left column (your
1274
+ * nav / conversation list)" — two answers, no way to tell which the kit meant.
1275
+ * Now there is one answer and this reads it; revising the recipe moves the
1276
+ * emitters with it.
1277
+ *
1278
+ * Deliberately not a Map built once at module scope: the recipes are a handful
1279
+ * of records, this runs a few times per request, and a lazily-built cache is a
1280
+ * second place for the answer to live.
1281
+ */
1282
+ function slotPlacementFor(tag: string): { parent: string; slot: string } | null {
1283
+ for (const r of surfaceRecipes) {
1284
+ for (const c of r.composition ?? []) {
1285
+ if (c.child === tag) return { parent: c.parent, slot: c.slot };
1286
+ }
1287
+ }
1288
+ return null;
1289
+ }
1290
+
1291
+ /**
1292
+ * The tag every front end emits whether or not the caller asked for it.
1293
+ *
1294
+ * The input schema only ADVISES including it ("usually kai-chat plus …"), and
1295
+ * every renderer here builds its surface around the chat element regardless. So
1296
+ * "is `kai-chat` in `components`" is a question about the REQUEST, never about
1297
+ * what gets emitted, and the two must not be confused — see `slottedInChat`.
1298
+ */
1299
+ const ALWAYS_EMITTED_TAG = 'kai-chat';
1300
+
1301
+ /**
1302
+ * The companions this surface SLOTS into `<kai-chat>`, in `components` order.
1303
+ *
1304
+ * ★ THIS GATED ON `components.includes('kai-chat')` AND THAT WAS THE BUG BACK.
1305
+ * The rationale read well — do not point a `slot=` at an element that is not
1306
+ * there — and it was false for the only parent this function admits: the chat is
1307
+ * emitted unconditionally, so the check could never protect anything and could
1308
+ * only ever fire on a request that omitted a tag the output contains anyway.
1309
+ * Measured on `components: ['kai-conversations']`, it reproduced the original
1310
+ * defect verbatim — `<kai-chat></kai-chat>` above a bare `<kai-conversations>`
1311
+ * under "wire data props", the exact emit this whole change exists to remove.
1312
+ * `scaffold.test.ts` drives both shapes now.
1313
+ *
1314
+ * A future placement under a parent that is NOT always emitted would need that
1315
+ * check back, scoped to that parent. It does not need it for this one.
1316
+ */
1317
+ function slottedInChat(components: readonly string[]): { tag: string; slot: string }[] {
1318
+ return components.flatMap((tag) => {
1319
+ const placement = slotPlacementFor(tag);
1320
+ if (!placement || placement.parent !== ALWAYS_EMITTED_TAG) return [];
1321
+ return [{ tag, slot: placement.slot }];
1322
+ });
1323
+ }
1324
+
1325
+ /**
1326
+ * What the React family says instead, because it cannot say the other thing.
1327
+ *
1328
+ * The catalog composes the rail INSIDE `<kai-chat>` on the `sidebar` slot, and
1329
+ * the generated React wrappers cannot express that: `WebComponentProps`
1330
+ * (frameworks/react/runtime.tsx) declares `theme`, `className`, `style`, `id`
1331
+ * and `children` and nothing else, and the runtime's `createElement` forwards
1332
+ * exactly those four — so `<Conversations slot="sidebar" />` is
1333
+ * `error TS2322: Property 'slot' does not exist`, measured with the same tsc
1334
+ * `verify:scaffold` runs, and would not reach the DOM even if it compiled.
1335
+ *
1336
+ * So this target emits the sibling it CAN emit and says which one it is. The
1337
+ * alternative — quietly emitting the layout the catalog does not describe, under
1338
+ * a comment claiming the composition — is how the original defect read.
1339
+ */
1340
+ function railSiblingNote(tag: string, placement: { parent: string; slot: string }): string[] {
1341
+ const w = conversationsWiring();
1342
+ // The wrapper's handler prop, DERIVED the way the wrapper generator derives it:
1343
+ // drop the `kai-` prefix, PascalCase the rest, prefix `on`. That is what makes
1344
+ // `kai-conversation-select` into `onConversationSelect` in react/index.tsx.
1345
+ const handler = `on${toPascalCase(w.event)}`;
1346
+ return [
1347
+ ` {/* NOT WIRED, and NOT SLOTTED — both deliberate, both fixable here.`,
1348
+ ` COMPOSITION: the kit composes this as <${tag} slot="${placement.slot}"> INSIDE`,
1349
+ ` <${placement.parent}>, which renders it into a fixed-width ::part(${placement.slot}) column.`,
1350
+ ` The generated React wrappers take no \`slot\` prop (see WebComponentProps`,
1351
+ ` in @kitn.ai/ui/react), so this renders as a sibling instead. Drop to the`,
1352
+ ` raw <${placement.parent}> custom element if you want the slotted layout.`,
1353
+ ` WIRING: set ${w.property}={...} and ${handler}={...} — the handler`,
1354
+ ` reads event.detail.id and calls setMessages with that thread. */}`,
1355
+ ];
1356
+ }
1357
+
1358
+ /** Wrap prose in one `<!-- … -->` block at `pad`, closing on the LAST line
1359
+ * whatever the length — an index-counted closer silently stops closing the day
1360
+ * the prose grows a line. */
1361
+ function htmlComment(lines: string[], pad: string): string[] {
1362
+ return lines.map(
1363
+ (l, i) => `${pad}${i === 0 ? '<!-- ' : ' '}${l}${i === lines.length - 1 ? ' -->' : ''}`,
1364
+ );
1365
+ }
1366
+
1367
+ /**
1368
+ * The slotted companions as markup, for every target that writes real tags:
1369
+ * html, vue, svelte and angular all spell an element and an attribute the same
1370
+ * way, so they share this rather than carrying four copies of the decision.
1371
+ *
1372
+ * `wired` is what the target actually DID, not what it should have done. The
1373
+ * html target emits the full wiring in `htmlModule` and passes true; the others
1374
+ * pass false and get the notice, because the alternative — a rail that renders
1375
+ * and does nothing, under a comment that says "wire data props" — is the defect
1376
+ * this whole change is about. The React family is absent from this list on
1377
+ * purpose: `WebComponentProps` in frameworks/react/runtime.tsx has no `slot`,
1378
+ * and the runtime does not forward one, so a `<Conversations slot="sidebar" />`
1379
+ * would neither typecheck nor render there. See `railSiblingNote`.
1380
+ */
1381
+ function slottedChildMarkup(
1382
+ components: readonly string[],
1383
+ pad: string,
1384
+ opts: { wired: boolean; ids: boolean },
1385
+ ): string[] {
1386
+ const w = conversationsWiring();
1387
+ return slottedInChat(components).flatMap((s) => [
1388
+ // What the shell DOES, and — the half a comment gets wrong by being generous
1389
+ // — what it does not. `<kai-chat>`'s sidebar region is one fixed-width aside
1390
+ // (`chat-thread.tsx`), exposed as a part and nothing more: it does not listen
1391
+ // for the rail's collapse, and it has no responsive behaviour, so a rail that
1392
+ // collapses inside it leaves the column exactly as wide as it was.
1393
+ ...htmlComment(
1394
+ [
1395
+ `SLOTTED, not a sibling: <kai-chat> renders this into its own`,
1396
+ `::part(${s.slot}) aside — a fixed-width column, and that is the whole of`,
1397
+ `what the shell does with it. Collapsing the rail does NOT narrow the`,
1398
+ `column: use the <kai-workspace> layout shell (start aside: startCollapsed /`,
1399
+ `collapseBelow / drawer-below) or your own layout if it has to collapse or resize.${opts.wired ? ' src/main.ts drives the rail.' : ''}`,
1400
+ ],
1401
+ pad,
1402
+ ),
1403
+ ...(opts.wired ? [] : htmlComment(notWiredNote(s.tag, w.property, w.event), pad)),
1404
+ `${pad}<${s.tag}${opts.ids ? ` id="${s.tag.replace(/^kai-/, '')}"` : ''} slot="${s.slot}" style="display:block;height:100%"></${s.tag}>`,
1405
+ ]);
1406
+ }
1407
+
1408
+ /**
1409
+ * What an emitted-but-unwired companion says about itself.
1410
+ *
1411
+ * The bar is the one the previous text failed: a builder pasted
1412
+ * `<!-- wire data props — see the component_reference MCP tool -->`, got a
1413
+ * working chat beside an empty grey column, and could not tell from the output
1414
+ * whether the rail was broken, unwired, or waiting on data — so it names the
1415
+ * PROPERTY and the EVENT, which is the whole of what is missing.
1416
+ *
1417
+ * "NOT WIRED" is the greppable half and the tests key on it. Keep the words.
1418
+ */
1419
+ function notWiredNote(tag: string, property: string, event: string): string[] {
1420
+ return [
1421
+ `NOT WIRED: <${tag}> needs el.${property} (a JS property — arrays are never`,
1422
+ `attributes) and a ${event} listener that sets kai-chat.messages from your own`,
1423
+ `thread store. The html target emits that wiring in full; ask the`,
1424
+ `component_reference MCP tool for the workspace-chat recipe to see the shape.`,
1425
+ ];
1426
+ }
1427
+
1428
+ /**
1429
+ * The rail's contract, in one place: the property to set and the event to hear.
1430
+ *
1431
+ * The EVENT is read off the recipe — that is precisely what a wiring edge is —
1432
+ * so renaming the event in the catalog renames it in every emitted comment. The
1433
+ * PROPERTY is not: a wiring edge names the property the event WRITES (`messages`
1434
+ * on `<kai-chat>`), never the one that feeds the source element, so `conversations`
1435
+ * is a copy of the element's own API and is registered here as one. The
1436
+ * fall-through keeps the emitted note truthful if the edge is ever removed.
1437
+ */
1438
+ function conversationsWiring(): { property: string; event: string } {
1439
+ const edge = surfaceRecipes
1440
+ .flatMap((r) => r.wiring)
1441
+ .find((w) => w.from === 'kai-conversations' && w.to === 'kai-chat' && w.property === 'messages');
1442
+ return { property: 'conversations', event: edge?.event ?? 'kai-conversation-select' };
1443
+ }
1444
+
1010
1445
  // ── attachments ───────────────────────────────────────────────────────────────
1011
1446
 
1012
1447
  /**
@@ -1024,7 +1459,7 @@ const ATTACHMENT_TAGS = new Set(['kai-file-upload', 'kai-attachments']);
1024
1459
 
1025
1460
  /**
1026
1461
  * True when the surface stages attachments — BOTH tags, for the same reason
1027
- * `isWorkspace` needs both.
1462
+ * `isArtifactSplit` needs both.
1028
1463
  *
1029
1464
  * They are one capability group in `archetypes.ts`, so in practice a request
1030
1465
  * carries both or neither. Requiring both here is what keeps a hand-built
@@ -1393,13 +1828,37 @@ interface RenderCtx {
1393
1828
  * block of its own, above the chat, with ids the emitted module wires together.
1394
1829
  */
1395
1830
  function componentTags(components: readonly string[], chatFill: string): string {
1831
+ const block = isWorkspaceBlock(components);
1832
+ // The catalog's nesting decision, applied: a slotted companion is a CHILD of
1833
+ // <kai-chat> and never a sibling in the column below it, so it comes out of the
1834
+ // companion loop the same way the workspace and attachment pairs do. In BLOCK
1835
+ // mode the rail moves to the SHELL's start aside instead, so the chat keeps no
1836
+ // sidebar child.
1837
+ const slotted = slottedInChat(components);
1838
+ const slottedTags = new Set(slotted.map((s) => s.tag));
1839
+ const chatSlotted = block ? slotted.filter((s) => !BLOCK_TAGS.has(s.tag)) : slotted;
1396
1840
  const companionTags = components.filter(
1397
1841
  (t) =>
1398
1842
  t !== 'kai-chat' &&
1399
1843
  !MESSAGE_EMBEDDED_TAGS.has(t) &&
1400
- !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
1844
+ !ARTIFACT_SPLIT_TAGS.has(t) &&
1845
+ !(block && BLOCK_TAGS.has(t)) &&
1846
+ !slottedTags.has(t) &&
1401
1847
  !(hasAttachments(components) && ATTACHMENT_TAGS.has(t)),
1402
1848
  );
1849
+ // `display:block;height:100%` because a custom element is inline by default and
1850
+ // an inline rail collapses to its text height in the shell's fixed-width
1851
+ // column. The same two declarations the chat-slots story puts on it.
1852
+ const chatBlock = (pad: string): string[] =>
1853
+ chatSlotted.length === 0
1854
+ ? [`${pad}<kai-chat id="chat" suggestion-mode="submit" style="${chatFill}"></kai-chat>`]
1855
+ : [
1856
+ `${pad}<kai-chat id="chat" suggestion-mode="submit" style="${chatFill}">`,
1857
+ // wired: `htmlModule` below emits the property, the two listeners and
1858
+ // the thread map. This is the one target that does.
1859
+ ...slottedChildMarkup(components, `${pad} `, { wired: true, ids: true }),
1860
+ `${pad}</kai-chat>`,
1861
+ ];
1403
1862
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
1404
1863
  const hasStandaloneCompanions = companionTags.length > 0;
1405
1864
 
@@ -1427,13 +1886,13 @@ function componentTags(components: readonly string[], chatFill: string): string
1427
1886
  // unreachable until the surface axis became a components list. Building the
1428
1887
  // chat block into `lines` instead means the companion loop below runs either
1429
1888
  // way, which is the property that was missing rather than a special case.
1430
- if (isWorkspace(components)) {
1889
+ if (isArtifactSplit(components)) {
1431
1890
  lines.push(
1432
1891
  ` <!-- SCAF-14: workspace split — chat pane left, artifact preview right. -->`,
1433
1892
  ` <!-- kai-resizable needs kai-resizable-item children to render panels. -->`,
1434
1893
  ` <kai-resizable orientation="horizontal" style="display:block;width:100%;height:100%">`,
1435
1894
  ` <kai-resizable-item size="40%" min="240px">`,
1436
- ` <kai-chat id="chat" suggestion-mode="submit" style="${chatFill}"></kai-chat>`,
1895
+ ...chatBlock(' '),
1437
1896
  ` </kai-resizable-item>`,
1438
1897
  ` <kai-resizable-item min="280px">`,
1439
1898
  ` <!-- Replace src with your artifact URL or set .files for multi-file preview. -->`,
@@ -1442,7 +1901,7 @@ function componentTags(components: readonly string[], chatFill: string): string
1442
1901
  ` </kai-resizable>`,
1443
1902
  );
1444
1903
  } else {
1445
- lines.push(` <kai-chat id="chat" suggestion-mode="submit" style="${chatFill}"></kai-chat>`);
1904
+ lines.push(...chatBlock(' '));
1446
1905
  }
1447
1906
 
1448
1907
  if (hasEmbedded) {
@@ -1469,6 +1928,30 @@ function componentTags(components: readonly string[], chatFill: string): string
1469
1928
  lines.push(` <!-- wire data props — see the component_reference MCP tool -->`);
1470
1929
  }
1471
1930
 
1931
+ // The workspace BLOCK wrap: the shell around everything above, the rail in
1932
+ // its start aside, the rest projected into its main region.
1933
+ if (block) {
1934
+ return [
1935
+ ` <!-- ── The workspace BLOCK: the shell + the rail + your thread records ── -->`,
1936
+ ...htmlComment(
1937
+ [
1938
+ `<kai-workspace> is the chat-agnostic layout shell: header/start/main/`,
1939
+ `end/footer slots, aside resize + collapse, and a mobile drawer below`,
1940
+ `the drawer-below width. It knows nothing about chat.`,
1941
+ ],
1942
+ ' ',
1943
+ ),
1944
+ ` <kai-workspace drawer-below="720" style="display:block;width:100%;height:100%">`,
1945
+ ...htmlComment([...BLOCK_RAIL_NOTE, `src/main.ts drives the rail.`], ' '),
1946
+ ` <kai-conversations id="conversations" slot="start" style="display:block;height:100%"></kai-conversations>`,
1947
+ ` <!-- Unnamed children project into the shell's MAIN region. -->`,
1948
+ ` <div style="display:flex;flex-direction:column;height:100%;min-height:0">`,
1949
+ ...lines.map((l) => (l === '' ? l : ` ${l}`)),
1950
+ ` </div>`,
1951
+ ` </kai-workspace>`,
1952
+ ].join('\n');
1953
+ }
1954
+
1472
1955
  return lines.join('\n');
1473
1956
  }
1474
1957
 
@@ -1503,6 +1986,10 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
1503
1986
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
1504
1987
  const hasSources = components.includes('kai-sources');
1505
1988
  const attachments = hasAttachments(components);
1989
+ const block = isWorkspaceBlock(components);
1990
+ // In BLOCK mode the rail is the shell's start aside driven by the block's
1991
+ // thread store below, not the in-memory slotted-in-chat wiring.
1992
+ const conversations = !block && slottedInChat(components).some((s) => s.tag === 'kai-conversations');
1506
1993
 
1507
1994
  // MODULE scope, like `model`/`runTool`: the staged list has to outlive `init()`
1508
1995
  // so the submit handler can read it, and `Staged` is used by the module-scope
@@ -1565,6 +2052,184 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
1565
2052
  ]
1566
2053
  : [];
1567
2054
 
2055
+ // The conversation rail, WIRED — the whole point of emitting it. The rail
2056
+ // renders what its `conversations` property holds and reports what the user
2057
+ // picked; swapping the thread is the host's job and nothing does it by itself
2058
+ // (host-coordinates). Both recipe edges are here: `kai-conversation-select`
2059
+ // loads a thread, `kai-new-chat` starts an empty one.
2060
+ //
2061
+ // NOTHING IS FABRICATED IN THE THREAD. `threads` starts EMPTY and fills with
2062
+ // what the user actually says, so selecting a seeded row opens a blank chat
2063
+ // rather than a conversation they never had — the rule `SAMPLE_AGENTIC_MESSAGE`
2064
+ // states for the message list, applied to the list of lists. The conversation
2065
+ // ROWS are placeholder chrome, marked as such, like `sampleSources`.
2066
+ //
2067
+ // ★ THE ROW IS ALSO KEPT IN STEP, and it was not. This wiring shipped with the
2068
+ // thread swap correct and the row inert: written once at creation and never
2069
+ // touched again, so after a real turn the rail read "New chat / 0 messages /
2070
+ // just now" beside a thread holding two messages — four dead fields, on the one
2071
+ // surface a consumer copies wholesale. `syncActiveRow` below is the fix, and it
2072
+ // is emitted as a worked example of the `reactivity-two-halves` invariant
2073
+ // because a consumer who copies HALF of it gets a rail that is silently stale
2074
+ // rather than one that is obviously broken. Run-guarded by
2075
+ // `tests/agent-tooling/emitted-conversation-rail.live.test.ts`, which reads the
2076
+ // rail's SHADOW DOM — no string assertion can tell the two halves apart.
2077
+ const conversationsSetupLines = conversations
2078
+ ? [
2079
+ ` const conversationsEl = document.getElementById('conversations') as KaiConversationsElement;`,
2080
+ ` // Same upgrade rule as <kai-chat>: 'conversations' is an array, so it is a`,
2081
+ ` // PROPERTY, and a property set before the element upgrades is dropped on upgrade.`,
2082
+ ` await customElements.whenDefined('kai-conversations');`,
2083
+ ` // The row shape, DERIVED from the property it is assigned to — it cannot`,
2084
+ ` // drift out of step with the element the way a hand-written copy would.`,
2085
+ ` type Conversation = KaiConversationsElement['conversations'][number];`,
2086
+ ` // Replace this list with your own. Your store owns titles, grouping and order;`,
2087
+ ` // the rail renders what it is given, in the order it is given.`,
2088
+ ` const now = new Date().toISOString();`,
2089
+ ` let conversationRows: Conversation[] = [`,
2090
+ ` { id: 'c1', title: 'New chat', scope: { type: 'document' }, messageCount: 0, lastMessageAt: now, updatedAt: now },`,
2091
+ ` ];`,
2092
+ ` let activeId = conversationRows[0].id;`,
2093
+ ` // The threads you have open, keyed by conversation id. It starts EMPTY on`,
2094
+ ` // purpose: seeding it would put words in the user's mouth. Load a thread`,
2095
+ ` // from your backend in the select handler below and this fills itself.`,
2096
+ ` const threads: Record<string, KaiChatElement['messages']> = {};`,
2097
+ ` // A NEW array per write — the array reference is what notifies.`,
2098
+ ` const showConversations = () => {`,
2099
+ ` conversationsEl.conversations = [...conversationRows];`,
2100
+ ` conversationsEl.activeId = activeId;`,
2101
+ ` };`,
2102
+ ` showConversations();`,
2103
+ ``,
2104
+ ` // The row's title, derived the way every chat app derives it: the first`,
2105
+ ` // thing the user said, on one line. Swap in your own — a summariser, or`,
2106
+ ` // whatever the user renamed the thread to — this is the fallback, not a`,
2107
+ ` // policy. Returns undefined for an empty thread so the row keeps the`,
2108
+ ` // placeholder it was created with rather than losing its label.`,
2109
+ ` const TITLE_MAX = 48;`,
2110
+ ` const titleFrom = (messages: KaiChatElement['messages']): string | undefined => {`,
2111
+ ` const first = messages.find((message) => message.role === 'user');`,
2112
+ ` // flatMap, not filter: a message can open with file parts, and this`,
2113
+ ` // narrows the part union without needing a type predicate.`,
2114
+ ` const text = (first?.parts ?? [])`,
2115
+ ` .flatMap((part) => (part.type === 'text' ? [part.text] : []))`,
2116
+ ` .join(' ')`,
2117
+ ` .replace(/\\s+/g, ' ')`,
2118
+ ` .trim();`,
2119
+ ` if (!text) return undefined;`,
2120
+ ` return text.length > TITLE_MAX ? \`\${text.slice(0, TITLE_MAX).trimEnd()}…\` : text;`,
2121
+ ` };`,
2122
+ ``,
2123
+ ` // The row and the thread are two views of one conversation, and only the`,
2124
+ ` // HOST keeps them in step: <kai-conversations> renders what it is handed and`,
2125
+ ` // reports what was clicked, nothing more. Without this the rail reads`,
2126
+ ` // "New chat / 0 messages / just now" forever, whatever the user does.`,
2127
+ ` const syncActiveRow = () => {`,
2128
+ ` const messages = chat.messages ?? [];`,
2129
+ ` const at = new Date().toISOString();`,
2130
+ ` // BOTH HALVES OF THE CONTRACT, and the rail is silently stale without`,
2131
+ ` // either. A NEW ARRAY is what NOTIFIES — handing the same array back is a`,
2132
+ ` // no-op even when a row inside it changed. A NEW OBJECT for the row that`,
2133
+ ` // changed is what makes it VISIBLE, because the rail renders rows through`,
2134
+ ` // a reference-keyed list and never re-renders a row whose identity held.`,
2135
+ ` // So: map to a fresh row. Never \`row.messageCount = …\` — that mutation`,
2136
+ ` // updates the data and leaves the screen showing the old numbers.`,
2137
+ ` conversationRows = conversationRows.map((row) =>`,
2138
+ ` row.id === activeId`,
2139
+ ` ? {`,
2140
+ ` ...row,`,
2141
+ ` title: titleFrom(messages) ?? row.title,`,
2142
+ ` messageCount: messages.length,`,
2143
+ ` lastMessageAt: at,`,
2144
+ ` updatedAt: at,`,
2145
+ ` }`,
2146
+ ` : row,`,
2147
+ ` );`,
2148
+ ` showConversations();`,
2149
+ ` };`,
2150
+ ``,
2151
+ ` conversationsEl.addEventListener('kai-conversation-select', (event: Event) => {`,
2152
+ ` const { id } = (event as CustomEvent<{ id: string }>).detail;`,
2153
+ ` // Keep what is on screen before switching away from it.`,
2154
+ ` threads[activeId] = chat.messages ?? [];`,
2155
+ ` activeId = id;`,
2156
+ ` // ...and load the one picked. Fetch it here if your store is remote.`,
2157
+ ` chat.messages = [...(threads[id] ?? [])];`,
2158
+ ` showConversations();`,
2159
+ ` });`,
2160
+ ` conversationsEl.addEventListener('kai-new-chat', () => {`,
2161
+ ` // The event carries no detail by design: it IS the whole signal.`,
2162
+ ` threads[activeId] = chat.messages ?? [];`,
2163
+ ` const at = new Date().toISOString();`,
2164
+ ` activeId = \`c\${conversationRows.length + 1}\`;`,
2165
+ ` conversationRows = [`,
2166
+ ` { id: activeId, title: 'New chat', scope: { type: 'document' }, messageCount: 0, lastMessageAt: at, updatedAt: at },`,
2167
+ ` ...conversationRows,`,
2168
+ ` ];`,
2169
+ ` chat.messages = [];`,
2170
+ ` showConversations();`,
2171
+ ` });`,
2172
+ ``,
2173
+ ]
2174
+ : [];
2175
+
2176
+ // The BLOCK's init-scope wiring: the thread store projected onto the rail and
2177
+ // the chat, the helpers owning the mechanics, every policy line labelled as
2178
+ // the consumer's. `showWorkspace` is a function declaration so the sessions
2179
+ // callback above it can name it before it is reached.
2180
+ const blockSetupLines = block
2181
+ ? [
2182
+ ` // ── The block's state: your records + the kit's session/persistence helpers ─`,
2183
+ ` const conversationsEl = document.getElementById('conversations') as KaiConversationsElement;`,
2184
+ ` // Same upgrade rule as <kai-chat>: 'conversations' is an array, so it is a`,
2185
+ ` // PROPERTY, and a property set before the element upgrades is dropped on upgrade.`,
2186
+ ` await customElements.whenDefined('kai-conversations');`,
2187
+ ``,
2188
+ ` let threads: Thread[] = loadThreads();`,
2189
+ ` // null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
2190
+ ` // record is created on its first turn, so the rail never fills with empties.`,
2191
+ ` let activeId: string | null = threads[0]?.id ?? null;`,
2192
+ ...BLOCK_SESSIONS_NOTE.map((l) => ` // ${l}`),
2193
+ ` const sessions = createThreadSessions(() => showWorkspace());`,
2194
+ ` // The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
2195
+ ` // the save target and what a failed save means are YOURS (see saveThreads).`,
2196
+ ` const saver = createSaveScheduler(saveThreads, { delayMs: 250 });`,
2197
+ ` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
2198
+ ` window.addEventListener('beforeunload', () => saver.flush());`,
2199
+ ` // Every write goes through this: schedule the save and re-project the views.`,
2200
+ ` const setThreads = (updater: (prev: Thread[]) => Thread[]): void => {`,
2201
+ ` threads = updater(threads);`,
2202
+ ` saver.schedule(threads);`,
2203
+ ` showWorkspace();`,
2204
+ ` };`,
2205
+ ``,
2206
+ ` // Project the records onto the elements. NEW arrays per write — the array`,
2207
+ ` // reference is what notifies (reactivity-two-halves; the changed objects`,
2208
+ ` // inside come from the helpers' folds).`,
2209
+ ` function showWorkspace(): void {`,
2210
+ ` // The rail's row shape is a VIEW of your record — titles and counts, no`,
2211
+ ` // message bodies. The rail renders the array in the order it is given.`,
2212
+ ` conversationsEl.conversations = threads.map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt }));`,
2213
+ ` conversationsEl.activeId = activeId ?? undefined;`,
2214
+ ` chat.messages = [...(threads.find((t) => t.id === activeId)?.messages ?? [])];`,
2215
+ ` chat.loading = activeId !== null && sessions.isStreaming(activeId);`,
2216
+ ` }`,
2217
+ ` showWorkspace();`,
2218
+ ``,
2219
+ ` conversationsEl.addEventListener('kai-conversation-select', (event: Event) => {`,
2220
+ ` activeId = (event as CustomEvent<{ id: string }>).detail.id;`,
2221
+ ` showWorkspace();`,
2222
+ ` });`,
2223
+ ` conversationsEl.addEventListener('kai-new-chat', () => {`,
2224
+ ` // The event carries no detail by design: it IS the whole signal. A DRAFT,`,
2225
+ ` // not a record — see activeId above.`,
2226
+ ` activeId = null;`,
2227
+ ` showWorkspace();`,
2228
+ ` });`,
2229
+ ``,
2230
+ ]
2231
+ : [];
2232
+
1568
2233
  // Module scope, like vue/angular: the handler below closes over all three, and
1569
2234
  // `runTool` is a function declaration rather than something wedged into init().
1570
2235
  const modelLines = ctx.defaultModel
@@ -1596,6 +2261,10 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
1596
2261
  'KaiChatElement',
1597
2262
  ...(hasSources ? ['KaiSourcesElement'] : []),
1598
2263
  ...(attachments ? ['KaiAttachmentsElement'] : []),
2264
+ // Same rule again: only when the rail is really on this surface. It types the
2265
+ // property assignments AND supplies the row type, so it is used twice over.
2266
+ // The block drives the same element from its own thread store.
2267
+ ...(conversations || block ? ['KaiConversationsElement'] : []),
1599
2268
  ].join(', ');
1600
2269
 
1601
2270
  /**
@@ -1607,8 +2276,11 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
1607
2276
  * `const history: ChatMessage[]`; the tool-loop shape's thread IS
1608
2277
  * `chat.messages`, already typed by `KaiChatElement`, and importing the type
1609
2278
  * there is a TS6133 that fails `npm run build` in a stock app.
2279
+ *
2280
+ * The BLOCK always references it: the `Thread` record's `messages` field is
2281
+ * `ChatMessage[]`, so in block mode the import is used whatever the loop shape.
1610
2282
  */
1611
- const annotatesChatMessage = !ctx.emitToolLoop;
2283
+ const annotatesChatMessage = !ctx.emitToolLoop || block;
1612
2284
 
1613
2285
  const head = [
1614
2286
  `// src/main.ts — the page's logic, in a module YOUR build type-checks.`,
@@ -1629,12 +2301,14 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
1629
2301
  cardTools: ctx.cardProvider !== null,
1630
2302
  mock: ctx.isMock,
1631
2303
  attachments,
2304
+ workspaceBlock: block,
1632
2305
  }),
1633
2306
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
1634
2307
  ``,
1635
2308
  ...(ctx.isMock ? [...mockResponderInit(), ``] : []),
1636
2309
  ...modelLines,
1637
2310
  ...attachmentModuleLines,
2311
+ ...(block ? [...blockThreadStoreLines(''), ``] : []),
1638
2312
  ...cardsLines,
1639
2313
  ...toolsLines,
1640
2314
  ...runnerLines,
@@ -1652,6 +2326,8 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
1652
2326
  ...seedLines,
1653
2327
  ...sourcesSetupLines,
1654
2328
  ...attachmentSetupLines,
2329
+ ...conversationsSetupLines,
2330
+ ...blockSetupLines,
1655
2331
  ];
1656
2332
 
1657
2333
  // `Event`, not `CustomEvent`: addEventListener with a custom event name hands
@@ -1675,34 +2351,76 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
1675
2351
  `void init();`,
1676
2352
  ];
1677
2353
 
2354
+ // The block's submit prelude: bind the turn to ONE thread id, create the
2355
+ // record on its first turn, and open the session.
2356
+ const blockPrelude = block
2357
+ ? [
2358
+ ...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
2359
+ `const targetId = activeId ?? crypto.randomUUID();`,
2360
+ `// The record is born on its first turn — with YOUR title policy.`,
2361
+ `setThreads((prev) => prev.some((t) => t.id === targetId)`,
2362
+ ` ? prev`,
2363
+ ` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
2364
+ `activeId = targetId;`,
2365
+ ...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
2366
+ `const setMessages = bindThreadMessages<Thread>(setThreads, targetId, {`,
2367
+ ` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
2368
+ `});`,
2369
+ `const controller = sessions.begin(targetId);`,
2370
+ `showWorkspace();`,
2371
+ ]
2372
+ : [];
1678
2373
  return [
1679
2374
  ...head,
1680
2375
  ` // messages is a JS PROPERTY (objects can't be HTML attributes)`,
1681
2376
  ...listenerOpen,
1682
2377
  realStreamBody({
1683
2378
  pad: ' ',
1684
- read: 'chat.messages',
1685
- commitSet: (expr) => `chat.messages = ${expr};`,
1686
- setterAdapter: '(fn) => { chat.messages = fn(chat.messages); }',
1687
- setLoading: (v) => `chat.loading = ${v};`,
2379
+ read: block ? `(threads.find((t) => t.id === targetId)?.messages ?? [])` : 'chat.messages',
2380
+ commitSet: block
2381
+ ? (expr) => `setMessages(() => ${expr});`
2382
+ : (expr) => `chat.messages = ${expr};`,
2383
+ setterAdapter: block ? 'setMessages' : '(fn) => { chat.messages = fn(chat.messages); }',
2384
+ setLoading: block
2385
+ ? (v) =>
2386
+ v === 'true'
2387
+ ? `// loading for THIS thread now derives from the sessions map (showWorkspace).`
2388
+ : `sessions.end(targetId, controller); showWorkspace();`
2389
+ : (v) => `chat.loading = ${v};`,
1688
2390
  bodyPayload: realBodyPayload({ defaultModel: ctx.defaultModel, tools: ctx.emitTools }),
1689
2391
  strictRoles: true,
1690
2392
  toolLoop: ctx.emitToolLoop,
1691
2393
  cards: ctx.emitCards,
1692
- thread: liveThreadBinding(
1693
- 'chat.messages',
1694
- '(fn) => { chat.messages = fn(chat.messages); }',
1695
- 'chat.messages ?? []',
1696
- ),
2394
+ thread: block
2395
+ ? blockLiveThread(`(threads.find((t) => t.id === targetId)?.messages ?? [])`)
2396
+ : liveThreadBinding(
2397
+ 'chat.messages',
2398
+ '(fn) => { chat.messages = fn(chat.messages); }',
2399
+ 'chat.messages ?? []',
2400
+ ),
1697
2401
  mock: ctx.isMock,
2402
+ ...(block ? { fetchSignal: 'controller.signal', afterValue: blockPrelude } : {}),
2403
+ // ONE call site, in the `finally`, and deliberately not also on submit. The
2404
+ // finally sees the WHOLE turn — the user's message and the assistant's — so
2405
+ // one call cannot leave the row disagreeing with the thread, and it runs on
2406
+ // the error path too, where the user's message is on the thread regardless.
2407
+ // The cost is that the title appears when the reply settles rather than the
2408
+ // instant the user hits send; call it right after `chat.messages = history`
2409
+ // as well if you want it sooner.
2410
+ afterTurn: conversations ? [`syncActiveRow(); // keep the rail's row in step with the thread`] : [],
1698
2411
  ...(attachments
1699
2412
  ? {
1700
2413
  filesExpr: 'files',
1701
- afterValue: attachmentTurnLines({
1702
- stagedExpr: 'staged',
1703
- clear: ['staged = [];', 'showStaged();'],
1704
- fromSubmitEvent: true,
1705
- }),
2414
+ // The attachment capture must read the staging list before anything
2415
+ // clears it, so it precedes the block's prelude.
2416
+ afterValue: [
2417
+ ...attachmentTurnLines({
2418
+ stagedExpr: 'staged',
2419
+ clear: ['staged = [];', 'showStaged();'],
2420
+ fromSubmitEvent: true,
2421
+ }),
2422
+ ...blockPrelude,
2423
+ ],
1706
2424
  }
1707
2425
  : {}),
1708
2426
  }),
@@ -1768,11 +2486,22 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1768
2486
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
1769
2487
 
1770
2488
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
1771
- const workspace = isWorkspace(components);
2489
+ const workspace = isArtifactSplit(components);
2490
+ const block = isWorkspaceBlock(components);
1772
2491
 
1773
2492
  // SCAF-9: exclude message-embedded tags from import list.
1774
2493
  // SCAF-14: workspace uses Resizable+ResizableItem+Artifact — keep them in the import list.
1775
- const renderableTags = components.filter((t) => !MESSAGE_EMBEDDED_TAGS.has(t));
2494
+ //
2495
+ // `ALWAYS_EMITTED_TAG` is unioned in for the reason it exists: this target
2496
+ // renders `<Chat …>` whether or not the caller listed `kai-chat`, so keying
2497
+ // the imports off `components` alone emitted a file that USES `Chat` and never
2498
+ // imports it. Measured on `components: ['kai-conversations']` — a TS2304 in
2499
+ // the consumer's own build. Every cell `verify:scaffold` compiles carries
2500
+ // kai-chat already (it is `BASE_COMPONENT`), so this changes no gated output;
2501
+ // it fixes the ungated shape the gate cannot see.
2502
+ const renderableTags = [
2503
+ ...new Set([ALWAYS_EMITTED_TAG, ...components.filter((t) => !MESSAGE_EMBEDDED_TAGS.has(t))]),
2504
+ ];
1776
2505
  // For workspace: replace 'kai-resizable' with 'kai-resizable-item' so we get ResizableItem too.
1777
2506
  const importTags = workspace
1778
2507
  ? [...new Set([...renderableTags.filter((t) => t !== 'kai-resizable'), 'kai-resizable', 'kai-resizable-item'])]
@@ -1783,13 +2512,15 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1783
2512
  const attachments = hasAttachments(components);
1784
2513
 
1785
2514
  // SCAF-9: standalone companion tags (not kai-chat, not message-embedded, not
1786
- // workspace-structural, and not the attachment pair — that gets a composed
1787
- // block of its own with state behind it).
2515
+ // workspace-structural, not the block's shell/rail pair — both structural
2516
+ // and not the attachment pair — that gets a composed block of its own with
2517
+ // state behind it).
1788
2518
  const standaloneCompanionTags = components.filter(
1789
2519
  (t) =>
1790
2520
  t !== 'kai-chat' &&
1791
2521
  !MESSAGE_EMBEDDED_TAGS.has(t) &&
1792
- !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
2522
+ !ARTIFACT_SPLIT_TAGS.has(t) &&
2523
+ !(block && BLOCK_TAGS.has(t)) &&
1793
2524
  !(attachments && ATTACHMENT_TAGS.has(t)),
1794
2525
  );
1795
2526
 
@@ -1808,6 +2539,12 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1808
2539
  ` {/* Replace sampleSources with your real data. */}`,
1809
2540
  ` <Sources sources={sampleSources} />`,
1810
2541
  );
2542
+ } else if (slotPlacementFor(t)) {
2543
+ // The catalog says where this one goes, and this target cannot put it
2544
+ // there — so it says so, rather than emitting a layout nothing describes.
2545
+ // The placement is PASSED IN, not restated: the note names the parent and
2546
+ // the slot, and both belong to the record.
2547
+ companionJsxLines.push(...railSiblingNote(t, slotPlacementFor(t)!), ` <${toPascalCase(t)} />`);
1811
2548
  } else {
1812
2549
  companionJsxLines.push(` {/* wire data props — see the component_reference MCP tool */}`);
1813
2550
  companionJsxLines.push(` <${toPascalCase(t)} />`);
@@ -1871,16 +2608,69 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1871
2608
  const mockInit = isMock ? mockResponderInit() : [];
1872
2609
 
1873
2610
  // SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE for the three ways
1874
- // one broke a real app.
1875
- const sampleMessagesInit = [
1876
- ...(hasEmbedded
1877
- ? sampleSeedComment(isMock, ' ', (literal) => [
1878
- `const sampleMessages: ChatMessage[] = [${literal}];`,
1879
- `const [messages, setMessages] = useState<ChatMessage[]>(sampleMessages);`,
1880
- ])
1881
- : []),
1882
- ` const [messages, setMessages] = useState<ChatMessage[]>([]);`,
1883
- ].join('\n');
2611
+ // one broke a real app. In BLOCK mode the thread state is the block's
2612
+ // multi-thread store below, so the single-thread pair is not emitted (and the
2613
+ // sample seed with it — the store starts from what localStorage really holds).
2614
+ const sampleMessagesInit = block
2615
+ ? [
2616
+ ` // ── The block's state: your records + the kit's session/persistence helpers ─`,
2617
+ ` const [threads, setThreads] = useState<Thread[]>(loadThreads);`,
2618
+ ` // null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
2619
+ ` // record is created on its first turn, so the rail never fills with empties.`,
2620
+ ` const [activeId, setActiveId] = useState<string | null>(threads[0]?.id ?? null);`,
2621
+ ` // One id per in-flight reply — a stream keeps running when you switch away,`,
2622
+ ` // so this cannot be a single boolean.`,
2623
+ ` const [streamingIds, setStreamingIds] = useState<string[]>([]);`,
2624
+ ...BLOCK_SESSIONS_NOTE.map((l) => ` // ${l}`),
2625
+ ` const [sessions] = useState(() => createThreadSessions(setStreamingIds));`,
2626
+ ` // The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
2627
+ ` // the save target and what a failed save means are YOURS (see saveThreads).`,
2628
+ ` const [saver] = useState(() => createSaveScheduler(saveThreads, { delayMs: 250 }));`,
2629
+ ` useEffect(() => { saver.schedule(threads); }, [saver, threads]);`,
2630
+ ` useEffect(() => {`,
2631
+ ` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
2632
+ ` const flush = () => saver.flush();`,
2633
+ ` window.addEventListener('beforeunload', flush);`,
2634
+ ` return () => window.removeEventListener('beforeunload', flush);`,
2635
+ ` }, [saver]);`,
2636
+ ``,
2637
+ ` const active = threads.find((t) => t.id === activeId) ?? null;`,
2638
+ ` const messages = active?.messages ?? NO_MESSAGES;`,
2639
+ ` const loading = activeId !== null && streamingIds.includes(activeId);`,
2640
+ ` // The rail's row shape is a VIEW of your record — titles and counts, no`,
2641
+ ` // message bodies. Newest-first is your ordering; the rail renders the array`,
2642
+ ` // in the order it is given.`,
2643
+ ` const rows = threads.map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt }));`,
2644
+ ].join('\n')
2645
+ : [
2646
+ ...(hasEmbedded
2647
+ ? sampleSeedComment(isMock, ' ', (literal) => [
2648
+ `const sampleMessages: ChatMessage[] = [${literal}];`,
2649
+ `const [messages, setMessages] = useState<ChatMessage[]>(sampleMessages);`,
2650
+ ])
2651
+ : []),
2652
+ ` const [messages, setMessages] = useState<ChatMessage[]>([]);`,
2653
+ ].join('\n');
2654
+
2655
+ // The block's module-scope pieces: the thread store (the consumer-owned
2656
+ // persistence boundary) plus the stable empty-thread reference.
2657
+ const blockModuleInit = block
2658
+ ? [
2659
+ // One joined entry so the JSX branches' blank-line filter cannot squash
2660
+ // the store's own paragraph breaks.
2661
+ [
2662
+ `/** A stable reference for the empty thread. \`messages\` is diffed by`,
2663
+ ` * reference, so a fresh \`[]\` every render would re-notify for nothing. */`,
2664
+ `const NO_MESSAGES: ChatMessage[] = [];`,
2665
+ ``,
2666
+ ...blockThreadStoreLines(''),
2667
+ ].join('\n'),
2668
+ ]
2669
+ : [];
2670
+
2671
+ // In block mode `loading` derives from the sessions map, so the single
2672
+ // boolean is not declared (and would fail noUnusedLocals if it were).
2673
+ const loadingInit = block ? [] : [` const [loading, setLoading] = useState(false);`];
1884
2674
 
1885
2675
  // SCAF-9: sample sources data for knowledge-base archetype.
1886
2676
  const sampleSourcesInit =
@@ -1916,24 +2706,139 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1916
2706
  const cardPropsNote = (pad: string): string[] =>
1917
2707
  ctx.emitCards ? jsxComment(CARD_PROP_COMMENT, pad) : [];
1918
2708
 
2709
+ // The block's submit prelude: bind the turn to ONE thread id, create the
2710
+ // record on its first turn, and open the session. Rides in `afterValue` so it
2711
+ // lands after the value guard (and after the attachment capture, which must
2712
+ // read the staging list before anything clears it).
2713
+ const blockPrelude = block
2714
+ ? [
2715
+ ...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
2716
+ `const targetId = activeId ?? crypto.randomUUID();`,
2717
+ `// The record is born on its first turn — with YOUR title policy.`,
2718
+ `setThreads((prev) => prev.some((t) => t.id === targetId)`,
2719
+ ` ? prev`,
2720
+ ` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
2721
+ `setActiveId(targetId);`,
2722
+ ...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
2723
+ `const setMessages = bindThreadMessages<Thread>(setThreads, targetId, {`,
2724
+ ` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
2725
+ `});`,
2726
+ `const controller = sessions.begin(targetId);`,
2727
+ ]
2728
+ : [];
2729
+
2730
+ // The turn-scoped React thread binding, seeded from the TARGET thread's
2731
+ // stored messages — the line that makes continuing an old conversation after
2732
+ // a reload a real continuation rather than a fresh start.
2733
+ const blockReactThread: ThreadBinding = {
2734
+ open: ({ pad, userMessage }) => [
2735
+ `${pad}// THE TURN OWNS THE THREAD (same rule as the single-thread scaffold): the`,
2736
+ `${pad}// loop below re-encodes it every round, and React state cannot be read`,
2737
+ `${pad}// back mid-turn. setMessages (the id-bound sink) just projects it.`,
2738
+ `${pad}let thread: ChatMessage[] = [...(threads.find((t) => t.id === targetId)?.messages ?? []), ${userMessage}];`,
2739
+ `${pad}const set: SetMessages = (fn) => { thread = fn(thread); setMessages(() => thread); };`,
2740
+ `${pad}setMessages(() => thread);`,
2741
+ ],
2742
+ live: 'thread',
2743
+ setter: 'set',
2744
+ };
2745
+
1919
2746
  // onSubmit body. The mock and the real backend differ by ONE expression — the
1920
2747
  // stream's source — and share every other line: see `realStreamBody`.
1921
2748
  const onSubmitBody = realStreamBody({
1922
2749
  pad: ' ',
1923
- read: 'messages',
1924
- commitSet: (expr) => `setMessages(${expr});`,
1925
- // useState's setter IS a SetMessages: both are (updater) => void.
2750
+ read: block ? `(threads.find((t) => t.id === targetId)?.messages ?? [])` : 'messages',
2751
+ commitSet: block ? (expr) => `setMessages(() => ${expr});` : (expr) => `setMessages(${expr});`,
2752
+ // useState's setter IS a SetMessages: both are (updater) => void — and in
2753
+ // block mode the id-bound sink from bindThreadMessages is one too.
1926
2754
  setterAdapter: 'setMessages',
1927
- setLoading: (v) => `setLoading(${v});`,
2755
+ setLoading: block
2756
+ ? (v) =>
2757
+ v === 'true'
2758
+ ? `// loading for THIS thread now derives from the sessions map (streamingIds).`
2759
+ : `sessions.end(targetId, controller);`
2760
+ : (v) => `setLoading(${v});`,
1928
2761
  bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
1929
2762
  strictRoles: true,
1930
2763
  toolLoop: emitToolLoop,
1931
2764
  cards: ctx.emitCards,
1932
- thread: REACT_THREAD,
2765
+ thread: block ? blockReactThread : REACT_THREAD,
1933
2766
  mock: isMock,
2767
+ ...(block ? { fetchSignal: 'controller.signal' } : {}),
1934
2768
  ...attachmentSubmitOpts,
2769
+ ...(block
2770
+ ? {
2771
+ afterValue: [
2772
+ ...((attachmentSubmitOpts as { afterValue?: string[] }).afterValue ?? []),
2773
+ ...blockPrelude,
2774
+ ],
2775
+ }
2776
+ : {}),
1935
2777
  });
1936
2778
 
2779
+ // The surface JSX, shared by the next and plain-react branches below: the
2780
+ // attachment strip, the chat (or the artifact split around it) and the
2781
+ // companions — wrapped by the shell when this is the workspace BLOCK.
2782
+ const chatJsx = (pad: string): string[] => [
2783
+ ...cardPropsNote(pad),
2784
+ `${pad}<Chat`,
2785
+ `${pad} messages={messages}`,
2786
+ `${pad} loading={loading}`,
2787
+ `${pad} suggestions={suggestions}`,
2788
+ `${pad} suggestionMode="submit"`,
2789
+ ...cardProps(`${pad} `),
2790
+ `${pad} onSubmit={onSubmit}`,
2791
+ `${pad} style={{ ${jsxStyle(p.chatFill)} }}`,
2792
+ `${pad}/>`,
2793
+ ];
2794
+ const splitJsx: string[] = [
2795
+ ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
2796
+ ` {/* Resizable needs ResizableItem children to render panels. */}`,
2797
+ ` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
2798
+ ` <ResizableItem size="40%" min="240px">`,
2799
+ ...chatJsx(' '),
2800
+ ` </ResizableItem>`,
2801
+ ` <ResizableItem min="280px">`,
2802
+ ` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
2803
+ ` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
2804
+ ` </ResizableItem>`,
2805
+ ` </Resizable>`,
2806
+ ];
2807
+ // Standalone companions are siblings of the SPLIT (or of the chat): the split
2808
+ // owns chat + artifact, and a sources panel or a voice input belongs beside
2809
+ // it. Dropping them here is what the old workspace branch used to do.
2810
+ const mainJsx: string[] = [
2811
+ ...(attachmentJsx ? [attachmentJsx] : []),
2812
+ ...(workspace ? [...splitJsx, companions] : [...chatJsx(' '), companions]),
2813
+ ];
2814
+ const surfaceJsx: string[] = block
2815
+ ? [
2816
+ ` {/* ── The workspace BLOCK: the shell + the rail + your thread records ── */}`,
2817
+ ` {/* <Workspace> is the chat-agnostic layout shell (header/start/main/end/`,
2818
+ ` footer slots, aside resize + collapse, a mobile drawer below`,
2819
+ ` drawerBelow px). It knows nothing about chat. */}`,
2820
+ ` <Workspace drawerBelow={720} style={{ display: 'block', width: '100%', height: '100%' }}>`,
2821
+ ...jsxComment(BLOCK_RAIL_NOTE, ' '),
2822
+ ` {/* The React wrappers forward no slot prop, so a plain div carries the`,
2823
+ ` slot attribute into the shell's start aside. */}`,
2824
+ ` <div slot="start" style={{ height: '100%' }}>`,
2825
+ ` {/* New chat = a DRAFT (activeId null): the record appears on the first turn. */}`,
2826
+ ` <Conversations`,
2827
+ ` conversations={rows}`,
2828
+ ` activeId={activeId ?? undefined}`,
2829
+ ` onConversationSelect={(e) => setActiveId(e.detail.id)}`,
2830
+ ` onNewChat={() => setActiveId(null)}`,
2831
+ ` style={{ display: 'block', height: '100%' }}`,
2832
+ ` />`,
2833
+ ` </div>`,
2834
+ ` {/* Unnamed children project into the shell's MAIN region. */}`,
2835
+ ` <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>`,
2836
+ ...indentEmitted(mainJsx, ' '),
2837
+ ` </div>`,
2838
+ ` </Workspace>`,
2839
+ ]
2840
+ : mainJsx;
2841
+
1937
2842
  // SCAF-2: Next.js App Router requires 'use client' for components that use hooks/interactivity.
1938
2843
  const useClientDirective = framework === 'next' ? [`'use client';`, ``] : [];
1939
2844
 
@@ -1957,7 +2862,7 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1957
2862
  return [
1958
2863
  // 'use client' must be the very first line for Next.js App Router.
1959
2864
  ...useClientDirective,
1960
- `import { useState } from 'react';`,
2865
+ `import { ${block ? 'useEffect, useState' : 'useState'} } from 'react';`,
1961
2866
  `import dynamic from 'next/dynamic';`,
1962
2867
  // The adapter is pure parsing + pure state; both entries are SSR-import-safe,
1963
2868
  // so they stay static imports even though the ELEMENTS have to be dynamic.
@@ -1969,6 +2874,7 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1969
2874
  cardTools: ctx.cardProvider !== null,
1970
2875
  mock: isMock,
1971
2876
  attachments,
2877
+ workspaceBlock: block,
1972
2878
  }),
1973
2879
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
1974
2880
  `// <kai-*> are client-only custom elements (the server has no customElements`,
@@ -1982,10 +2888,11 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1982
2888
  ...mockInit,
1983
2889
  ``,
1984
2890
  ...attachmentModuleInit,
2891
+ ...blockModuleInit,
1985
2892
  ...cardsInit,
1986
2893
  `export default function App() {`,
1987
2894
  sampleMessagesInit,
1988
- ` const [loading, setLoading] = useState(false);`,
2895
+ ...loadingInit,
1989
2896
  ` const suggestions = ${jsArray(suggestions)};`,
1990
2897
  ...(attachmentStateInit ? [attachmentStateInit] : []),
1991
2898
  ...(sampleSourcesInit ? [sampleSourcesInit] : []),
@@ -1999,48 +2906,7 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1999
2906
  ``,
2000
2907
  ` return (`,
2001
2908
  ` <div style={{ ${jsxStyle(p.style)} }}>`,
2002
- ...(attachmentJsx ? [attachmentJsx] : []),
2003
- ...(workspace
2004
- ? [
2005
- ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
2006
- ` {/* Resizable needs ResizableItem children to render panels. */}`,
2007
- ` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
2008
- ` <ResizableItem size="40%" min="240px">`,
2009
- ...cardPropsNote(' '),
2010
- ` <Chat`,
2011
- ` messages={messages}`,
2012
- ` loading={loading}`,
2013
- ` suggestions={suggestions}`,
2014
- ` suggestionMode="submit"`,
2015
- ...cardProps(' '),
2016
- ` onSubmit={onSubmit}`,
2017
- ` style={{ ${jsxStyle(p.chatFill)} }}`,
2018
- ` />`,
2019
- ` </ResizableItem>`,
2020
- ` <ResizableItem min="280px">`,
2021
- ` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
2022
- ` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
2023
- ` </ResizableItem>`,
2024
- ` </Resizable>`,
2025
- // Standalone companions are siblings of the SPLIT, not of the chat:
2026
- // the split owns chat + artifact, and a sources panel or a voice input
2027
- // belongs beside it. Dropping them here is what the workspace branch
2028
- // used to do — see WORKSPACE_STRUCTURAL_TAGS.
2029
- companions,
2030
- ]
2031
- : [
2032
- ...cardPropsNote(' '),
2033
- ` <Chat`,
2034
- ` messages={messages}`,
2035
- ` loading={loading}`,
2036
- ` suggestions={suggestions}`,
2037
- ` suggestionMode="submit"`,
2038
- ...cardProps(' '),
2039
- ` onSubmit={onSubmit}`,
2040
- ` style={{ ${jsxStyle(p.chatFill)} }}`,
2041
- ` />`,
2042
- companions,
2043
- ]),
2909
+ ...surfaceJsx,
2044
2910
  ` </div>`,
2045
2911
  ` );`,
2046
2912
  `}`,
@@ -2058,7 +2924,7 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
2058
2924
  // (1) REQUIRED: registers <kai-*> — the react wrappers do NOT auto-register.
2059
2925
  // Must come BEFORE importing the wrappers, or <kai-chat> renders empty.
2060
2926
  `import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
2061
- `import { useState } from 'react';`,
2927
+ `import { ${block ? 'useEffect, useState' : 'useState'} } from 'react';`,
2062
2928
  `import { ${importList} } from '@kitn.ai/ui/react';`,
2063
2929
  ...wireImportLines({
2064
2930
  typed: true,
@@ -2068,6 +2934,7 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
2068
2934
  cardTools: ctx.cardProvider !== null,
2069
2935
  mock: isMock,
2070
2936
  attachments,
2937
+ workspaceBlock: block,
2071
2938
  }),
2072
2939
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
2073
2940
  ``,
@@ -2077,10 +2944,11 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
2077
2944
  ...mockInit,
2078
2945
  ``,
2079
2946
  ...attachmentModuleInit,
2947
+ ...blockModuleInit,
2080
2948
  ...cardsInit,
2081
2949
  `export default function App() {`,
2082
2950
  sampleMessagesInit,
2083
- ` const [loading, setLoading] = useState(false);`,
2951
+ ...loadingInit,
2084
2952
  ` const suggestions = ${jsArray(suggestions)};`,
2085
2953
  ...(attachmentStateInit ? [attachmentStateInit] : []),
2086
2954
  ...(sampleSourcesInit ? [sampleSourcesInit] : []),
@@ -2094,45 +2962,7 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
2094
2962
  ``,
2095
2963
  ` return (`,
2096
2964
  ` <div style={{ ${jsxStyle(p.style)} }}>`,
2097
- ...(attachmentJsx ? [attachmentJsx] : []),
2098
- ...(workspace
2099
- ? [
2100
- ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
2101
- ` {/* Resizable needs ResizableItem children to render panels. */}`,
2102
- ` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
2103
- ` <ResizableItem size="40%" min="240px">`,
2104
- ...cardPropsNote(' '),
2105
- ` <Chat`,
2106
- ` messages={messages}`,
2107
- ` loading={loading}`,
2108
- ` suggestions={suggestions}`,
2109
- ` suggestionMode="submit"`,
2110
- ...cardProps(' '),
2111
- ` onSubmit={onSubmit}`,
2112
- ` style={{ ${jsxStyle(p.chatFill)} }}`,
2113
- ` />`,
2114
- ` </ResizableItem>`,
2115
- ` <ResizableItem min="280px">`,
2116
- ` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
2117
- ` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
2118
- ` </ResizableItem>`,
2119
- ` </Resizable>`,
2120
- // Siblings of the SPLIT — see the same line in the other JSX branch.
2121
- companions,
2122
- ]
2123
- : [
2124
- ...cardPropsNote(' '),
2125
- ` <Chat`,
2126
- ` messages={messages}`,
2127
- ` loading={loading}`,
2128
- ` suggestions={suggestions}`,
2129
- ` suggestionMode="submit"`,
2130
- ...cardProps(' '),
2131
- ` onSubmit={onSubmit}`,
2132
- ` style={{ ${jsxStyle(p.chatFill)} }}`,
2133
- ` />`,
2134
- companions,
2135
- ]),
2965
+ ...surfaceJsx,
2136
2966
  ` </div>`,
2137
2967
  ` );`,
2138
2968
  `}`,
@@ -2147,13 +2977,22 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
2147
2977
 
2148
2978
  // SCAF-9: exclude message-embedded tags from companion rendering.
2149
2979
  // SCAF-14: also exclude workspace structural tags (handled by the workspace block below).
2150
- const workspace = isWorkspace(components);
2980
+ const workspace = isArtifactSplit(components);
2981
+ const block = isWorkspaceBlock(components);
2151
2982
  const attachments = hasAttachments(components);
2983
+ // Slotted companions are children of <kai-chat> (see `slottedChildMarkup`), so
2984
+ // they leave the sibling loop the same way they do in the html target. In
2985
+ // BLOCK mode the rail lives in the SHELL's start aside instead.
2986
+ const slottedTags = new Set(slottedInChat(components).map((s) => s.tag));
2987
+ const vueSlotted = (pad: string) =>
2988
+ block ? [] : slottedChildMarkup(components, pad, { wired: false, ids: false });
2152
2989
  const standaloneCompanionTags = components.filter(
2153
2990
  (t) =>
2154
2991
  t !== 'kai-chat' &&
2155
2992
  !MESSAGE_EMBEDDED_TAGS.has(t) &&
2156
- !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
2993
+ !ARTIFACT_SPLIT_TAGS.has(t) &&
2994
+ !(block && BLOCK_TAGS.has(t)) &&
2995
+ !slottedTags.has(t) &&
2157
2996
  !(attachments && ATTACHMENT_TAGS.has(t)),
2158
2997
  );
2159
2998
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
@@ -2224,19 +3063,57 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
2224
3063
  }
2225
3064
  : {};
2226
3065
 
3066
+ // The block's submit prelude — same shape as the other targets; refs instead
3067
+ // of React state.
3068
+ const blockRead = `(threads.value.find((t) => t.id === targetId)?.messages ?? [])`;
3069
+ const blockPrelude = block
3070
+ ? [
3071
+ ...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
3072
+ `const targetId = activeId.value ?? crypto.randomUUID();`,
3073
+ `// The record is born on its first turn — with YOUR title policy.`,
3074
+ `setThreads((prev) => prev.some((t) => t.id === targetId)`,
3075
+ ` ? prev`,
3076
+ ` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
3077
+ `activeId.value = targetId;`,
3078
+ ...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
3079
+ `const setMessages = bindThreadMessages<Thread>(setThreads, targetId, {`,
3080
+ ` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
3081
+ `});`,
3082
+ `const controller = sessions.begin(targetId);`,
3083
+ ]
3084
+ : [];
3085
+
2227
3086
  const onSubmitBody = realStreamBody({
2228
3087
  pad: ' ',
2229
- read: 'messages.value',
2230
- commitSet: (expr) => `messages.value = ${expr};`,
2231
- setterAdapter: '(fn) => { messages.value = fn(messages.value); }',
2232
- setLoading: (v) => `loading.value = ${v};`,
3088
+ read: block ? blockRead : 'messages.value',
3089
+ commitSet: block
3090
+ ? (expr) => `setMessages(() => ${expr});`
3091
+ : (expr) => `messages.value = ${expr};`,
3092
+ setterAdapter: block ? 'setMessages' : '(fn) => { messages.value = fn(messages.value); }',
3093
+ setLoading: block
3094
+ ? (v) =>
3095
+ v === 'true'
3096
+ ? `// loading for THIS thread now derives from the sessions map (streamingIds).`
3097
+ : `sessions.end(targetId, controller);`
3098
+ : (v) => `loading.value = ${v};`,
2233
3099
  bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
2234
3100
  strictRoles: true,
2235
3101
  toolLoop: emitToolLoop,
2236
3102
  cards: ctx.emitCards,
2237
- thread: liveThreadBinding('messages.value', '(fn) => { messages.value = fn(messages.value); }'),
3103
+ thread: block
3104
+ ? blockLiveThread(blockRead)
3105
+ : liveThreadBinding('messages.value', '(fn) => { messages.value = fn(messages.value); }'),
2238
3106
  mock: isMock,
3107
+ ...(block ? { fetchSignal: 'controller.signal' } : {}),
2239
3108
  ...attachmentSubmitOpts,
3109
+ ...(block
3110
+ ? {
3111
+ afterValue: [
3112
+ ...((attachmentSubmitOpts as { afterValue?: string[] }).afterValue ?? []),
3113
+ ...blockPrelude,
3114
+ ],
3115
+ }
3116
+ : {}),
2240
3117
  });
2241
3118
 
2242
3119
  // SCAF-10: ChatMessage declaration for strict-TS Vue consumers.
@@ -2266,15 +3143,51 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
2266
3143
  // Same scope again: the loop in onSubmit calls runTool.
2267
3144
  const runnerLines = emitToolLoop ? toolRunnerLines('', true) : [];
2268
3145
 
2269
- // SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE.
2270
- const sampleSeed = [
2271
- ...(hasEmbedded
2272
- ? sampleSeedComment(isMock, '', (literal) => [
2273
- `const messages = ref<ChatMessage[]>([${literal}]);`,
2274
- ])
2275
- : []),
2276
- `const messages = ref<ChatMessage[]>([]);`,
2277
- ];
3146
+ // SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE. In BLOCK mode the
3147
+ // thread state is the block's multi-thread store; `messages`/`loading` become
3148
+ // computed views of it.
3149
+ const sampleSeed = block
3150
+ ? [
3151
+ `// ── The block's state: your records + the kit's session/persistence helpers ──`,
3152
+ `const threads = ref<Thread[]>(loadThreads());`,
3153
+ `// null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
3154
+ `// record is created on its first turn, so the rail never fills with empties.`,
3155
+ `const activeId = ref<string | null>(threads.value[0]?.id ?? null);`,
3156
+ `// One id per in-flight reply — a stream keeps running when you switch away.`,
3157
+ `const streamingIds = ref<string[]>([]);`,
3158
+ ...BLOCK_SESSIONS_NOTE.map((l) => `// ${l}`),
3159
+ `const sessions = createThreadSessions((ids) => { streamingIds.value = ids; });`,
3160
+ `// The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
3161
+ `// the save target and what a failed save means are YOURS (see saveThreads).`,
3162
+ `const saver = createSaveScheduler(saveThreads, { delayMs: 250 });`,
3163
+ `watch(threads, () => saver.schedule(threads.value));`,
3164
+ `// Every write goes through this — a NEW array per write is what notifies.`,
3165
+ `const setThreads = (updater: (prev: Thread[]) => Thread[]): void => {`,
3166
+ ` threads.value = updater(threads.value);`,
3167
+ `};`,
3168
+ `const active = computed(() => threads.value.find((t) => t.id === activeId.value) ?? null);`,
3169
+ `const messages = computed(() => active.value?.messages ?? []);`,
3170
+ `const loading = computed(() => { const id = activeId.value; return id !== null && streamingIds.value.includes(id); });`,
3171
+ `// The rail's row shape is a VIEW of your record — titles and counts, no`,
3172
+ `// message bodies. The rail renders the array in the order it is given.`,
3173
+ `const rows = computed(() => threads.value.map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt })));`,
3174
+ ``,
3175
+ `function onSelect(event: Event) {`,
3176
+ ` activeId.value = (event as CustomEvent<{ id: string }>).detail.id;`,
3177
+ `}`,
3178
+ `function onNewChat() {`,
3179
+ ` // A DRAFT, not a record: it becomes a thread on its first turn.`,
3180
+ ` activeId.value = null;`,
3181
+ `}`,
3182
+ ]
3183
+ : [
3184
+ ...(hasEmbedded
3185
+ ? sampleSeedComment(isMock, '', (literal) => [
3186
+ `const messages = ref<ChatMessage[]>([${literal}]);`,
3187
+ ])
3188
+ : []),
3189
+ `const messages = ref<ChatMessage[]>([]);`,
3190
+ ];
2278
3191
 
2279
3192
  // SCAF-9: sample sources setup.
2280
3193
  const sourcesSeed = standaloneCompanionTags.includes('kai-sources')
@@ -2293,7 +3206,10 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
2293
3206
 
2294
3207
  // SCAF-15: always import onMounted — we re-apply props after the element upgrades
2295
3208
  // (the .prop bindings can apply before the async element registration resolves).
2296
- const vueImports = `import { ref, onMounted } from 'vue';`;
3209
+ // The block adds computed (the derived views) and watch (the save schedule).
3210
+ const vueImports = block
3211
+ ? `import { computed, onMounted, ref, watch } from 'vue';`
3212
+ : `import { ref, onMounted } from 'vue';`;
2297
3213
 
2298
3214
  // SCAF-14: workspace template block — resizable split with chat + artifact panes.
2299
3215
  const workspaceTemplate = workspace
@@ -2309,7 +3225,9 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
2309
3225
  ` suggestion-mode="submit"`,
2310
3226
  ` style="${p.chatFill}"`,
2311
3227
  ` @kai-submit="onSubmit"`,
2312
- ` ></kai-chat>`,
3228
+ ` >`,
3229
+ ...vueSlotted(' '),
3230
+ ` </kai-chat>`,
2313
3231
  ` </kai-resizable-item>`,
2314
3232
  ` <kai-resizable-item min="280px">`,
2315
3233
  ` <!-- Replace src with your artifact URL or set .files for multi-file preview. -->`,
@@ -2327,7 +3245,9 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
2327
3245
  ` suggestion-mode="submit"`,
2328
3246
  ` style="${p.chatFill}"`,
2329
3247
  ` @kai-submit="onSubmit"`,
2330
- ` ></kai-chat>`,
3248
+ ` >`,
3249
+ ...vueSlotted(' '),
3250
+ ` </kai-chat>`,
2331
3251
  companions,
2332
3252
  ];
2333
3253
 
@@ -2347,15 +3267,17 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
2347
3267
  cardTools: ctx.cardProvider !== null,
2348
3268
  mock: isMock,
2349
3269
  attachments,
3270
+ workspaceBlock: block,
2350
3271
  }),
2351
3272
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
2352
3273
  vueImports,
2353
3274
  ``,
2354
3275
  ...mockInit,
2355
3276
  ``,
3277
+ ...(block ? [blockThreadStoreLines('').join('\n'), ``] : []),
2356
3278
  ...cardsInit,
2357
3279
  ...sampleSeed,
2358
- `const loading = ref(false);`,
3280
+ ...(block ? [] : [`const loading = ref(false);`]),
2359
3281
  `const suggestions = ${jsArray(suggestions)};`,
2360
3282
  ...modelInit,
2361
3283
  ...toolsLines,
@@ -2371,6 +3293,16 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
2371
3293
  ...(ctx.emitCards ? CARD_PROP_COMMENT.map((l) => ` ${l}`) : []),
2372
3294
  ` const el = document.querySelector('kai-chat');`,
2373
3295
  ` if (el) Object.assign(el, { messages: messages.value, loading: loading.value, suggestions${cardPropAssign.length ? `, ${cardPropAssign.join(', ')}` : ''} });`,
3296
+ ...(block
3297
+ ? [
3298
+ ` // The rail's array props follow the same upgrade rule.`,
3299
+ ` await customElements.whenDefined('kai-conversations');`,
3300
+ ` const rail = document.querySelector('kai-conversations');`,
3301
+ ` if (rail) Object.assign(rail, { conversations: rows.value, activeId: activeId.value ?? undefined });`,
3302
+ ` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
3303
+ ` window.addEventListener('beforeunload', saver.flush);`,
3304
+ ]
3305
+ : []),
2374
3306
  `});`,
2375
3307
  ``,
2376
3308
  `async function onSubmit(e: ${attachments
@@ -2382,8 +3314,38 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
2382
3314
  ``,
2383
3315
  `<template>`,
2384
3316
  ` <div style="${p.style}">`,
2385
- ...(attachmentTemplate ? [attachmentTemplate] : []),
2386
- ...workspaceTemplate,
3317
+ ...(block
3318
+ ? [
3319
+ ` <!-- ── The workspace BLOCK: the shell + the rail + your thread records ── -->`,
3320
+ ...htmlComment(
3321
+ [
3322
+ `<kai-workspace> is the chat-agnostic layout shell: header/start/main/`,
3323
+ `end/footer slots, aside resize + collapse, and a mobile drawer below`,
3324
+ `the drawer-below width. It knows nothing about chat.`,
3325
+ ],
3326
+ ' ',
3327
+ ),
3328
+ ` <kai-workspace drawer-below="720" style="display:block;width:100%;height:100%">`,
3329
+ ...htmlComment(BLOCK_RAIL_NOTE, ' '),
3330
+ ` <!-- New chat = a DRAFT (activeId null): the record appears on the first turn. -->`,
3331
+ ` <kai-conversations`,
3332
+ ` slot="start"`,
3333
+ ` :conversations.prop="rows"`,
3334
+ ` :activeId.prop="activeId ?? undefined"`,
3335
+ ` style="display:block;height:100%"`,
3336
+ ` @kai-conversation-select="onSelect"`,
3337
+ ` @kai-new-chat="onNewChat"`,
3338
+ ` />`,
3339
+ ` <!-- Unnamed children project into the shell's MAIN region. -->`,
3340
+ ` <div style="display:flex;flex-direction:column;height:100%;min-height:0">`,
3341
+ ...indentEmitted(
3342
+ [...(attachmentTemplate ? [attachmentTemplate] : []), ...workspaceTemplate],
3343
+ ' ',
3344
+ ),
3345
+ ` </div>`,
3346
+ ` </kai-workspace>`,
3347
+ ]
3348
+ : [...(attachmentTemplate ? [attachmentTemplate] : []), ...workspaceTemplate]),
2387
3349
  ` </div>`,
2388
3350
  `</template>`,
2389
3351
  ]
@@ -2397,13 +3359,22 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
2397
3359
 
2398
3360
  // SCAF-9: exclude message-embedded tags from companion rendering.
2399
3361
  // SCAF-14: also exclude workspace structural tags (handled by the workspace block below).
2400
- const workspace = isWorkspace(components);
3362
+ const workspace = isArtifactSplit(components);
3363
+ const block = isWorkspaceBlock(components);
2401
3364
  const attachments = hasAttachments(components);
3365
+ // Slotted companions are children of <kai-chat>, not siblings — same rule the
3366
+ // html and vue targets follow, read from the same catalog record. In BLOCK
3367
+ // mode the rail lives in the SHELL's start aside instead.
3368
+ const slottedTags = new Set(slottedInChat(components).map((s) => s.tag));
3369
+ const svelteSlotted = (pad: string) =>
3370
+ block ? [] : slottedChildMarkup(components, pad, { wired: false, ids: false });
2402
3371
  const standaloneCompanionTags = components.filter(
2403
3372
  (t) =>
2404
3373
  t !== 'kai-chat' &&
2405
3374
  !MESSAGE_EMBEDDED_TAGS.has(t) &&
2406
- !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
3375
+ !ARTIFACT_SPLIT_TAGS.has(t) &&
3376
+ !(block && BLOCK_TAGS.has(t)) &&
3377
+ !slottedTags.has(t) &&
2407
3378
  !(attachments && ATTACHMENT_TAGS.has(t)),
2408
3379
  );
2409
3380
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
@@ -2437,19 +3408,54 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
2437
3408
  }
2438
3409
  : {};
2439
3410
 
3411
+ // The block's submit prelude — runes state instead of refs; same shape.
3412
+ const blockRead = `(threads.find((t) => t.id === targetId)?.messages ?? [])`;
3413
+ const blockPrelude = block
3414
+ ? [
3415
+ ...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
3416
+ `const targetId = activeId ?? crypto.randomUUID();`,
3417
+ `// The record is born on its first turn — with YOUR title policy.`,
3418
+ `setThreads((prev) => prev.some((t) => t.id === targetId)`,
3419
+ ` ? prev`,
3420
+ ` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
3421
+ `activeId = targetId;`,
3422
+ ...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
3423
+ `const setMessages = bindThreadMessages<Thread>(setThreads, targetId, {`,
3424
+ ` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
3425
+ `});`,
3426
+ `const controller = sessions.begin(targetId);`,
3427
+ ]
3428
+ : [];
3429
+
2440
3430
  const onSubmitBody = realStreamBody({
2441
3431
  pad: ' ',
2442
- read: 'messages',
2443
- commitSet: (expr) => `messages = ${expr};`,
2444
- setterAdapter: '(fn) => { messages = fn(messages); }',
2445
- setLoading: (v) => `loading = ${v};`,
3432
+ read: block ? blockRead : 'messages',
3433
+ commitSet: block ? (expr) => `setMessages(() => ${expr});` : (expr) => `messages = ${expr};`,
3434
+ setterAdapter: block ? 'setMessages' : '(fn) => { messages = fn(messages); }',
3435
+ setLoading: block
3436
+ ? (v) =>
3437
+ v === 'true'
3438
+ ? `// loading for THIS thread now derives from the sessions map (streamingIds).`
3439
+ : `sessions.end(targetId, controller);`
3440
+ : (v) => `loading = ${v};`,
2446
3441
  bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
2447
3442
  strictRoles: true,
2448
3443
  toolLoop: emitToolLoop,
2449
3444
  cards: ctx.emitCards,
2450
- thread: liveThreadBinding('messages', '(fn) => { messages = fn(messages); }'),
3445
+ thread: block
3446
+ ? blockLiveThread(blockRead)
3447
+ : liveThreadBinding('messages', '(fn) => { messages = fn(messages); }'),
2451
3448
  mock: isMock,
3449
+ ...(block ? { fetchSignal: 'controller.signal' } : {}),
2452
3450
  ...attachmentSubmitOpts,
3451
+ ...(block
3452
+ ? {
3453
+ afterValue: [
3454
+ ...((attachmentSubmitOpts as { afterValue?: string[] }).afterValue ?? []),
3455
+ ...blockPrelude,
3456
+ ],
3457
+ }
3458
+ : {}),
2453
3459
  });
2454
3460
 
2455
3461
  // SCAF-10: ChatMessage declaration for strict-TS Svelte consumers.
@@ -2483,14 +3489,55 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
2483
3489
  // chunk (mutating in place does not re-render), which is exactly what raw state
2484
3490
  // tracks. Deep state would also proxy every message object on its way into a
2485
3491
  // Solid-backed custom element, for reactivity this code never relies on.
2486
- const sampleMessagesInit = [
2487
- ...(hasEmbedded
2488
- ? sampleSeedComment(isMock, ' ', (literal) => [
2489
- `let messages = $state.raw<ChatMessage[]>([${literal}]);`,
2490
- ])
2491
- : []),
2492
- ` let messages = $state.raw<ChatMessage[]>([]);`,
2493
- ];
3492
+ const sampleMessagesInit = block
3493
+ ? [
3494
+ ` // ── The block's state: your records + the kit's session/persistence helpers ─`,
3495
+ ` // $state.raw for the same reason messages was: the kit's contract is a NEW`,
3496
+ ` // array reference per write, which is exactly what raw state tracks.`,
3497
+ ` let threads = $state.raw<Thread[]>(loadThreads());`,
3498
+ ` // null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
3499
+ ` // record is created on its first turn, so the rail never fills with empties.`,
3500
+ ` let activeId = $state<string | null>(threads[0]?.id ?? null);`,
3501
+ ` // One id per in-flight reply — a stream keeps running when you switch away.`,
3502
+ ` let streamingIds = $state.raw<string[]>([]);`,
3503
+ ...BLOCK_SESSIONS_NOTE.map((l) => ` // ${l}`),
3504
+ ` const sessions = createThreadSessions((ids) => { streamingIds = ids; });`,
3505
+ ` // The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
3506
+ ` // the save target and what a failed save means are YOURS (see saveThreads).`,
3507
+ ` const saver = createSaveScheduler(saveThreads, { delayMs: 250 });`,
3508
+ ` $effect(() => { saver.schedule(threads); });`,
3509
+ ` // Every write goes through this — a NEW array per write is what notifies.`,
3510
+ ` const setThreads = (updater: (prev: Thread[]) => Thread[]): void => {`,
3511
+ ` threads = updater(threads);`,
3512
+ ` };`,
3513
+ ` const active = $derived(threads.find((t) => t.id === activeId) ?? null);`,
3514
+ ` const messages = $derived(active?.messages ?? []);`,
3515
+ ` const loading = $derived.by(() => { const id = activeId; return id !== null && streamingIds.includes(id); });`,
3516
+ ` // The rail's row shape is a VIEW of your record — titles and counts, no`,
3517
+ ` // message bodies. The rail renders the array in the order it is given.`,
3518
+ ` const rows = $derived(threads.map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt })));`,
3519
+ ` // \`bind:this\` writes to this binding, so under runes it must be $state.`,
3520
+ ` let railEl = $state<KaiConversationsElement | undefined>(undefined);`,
3521
+ ` $effect(() => {`,
3522
+ ` if (railEl && defined) { railEl.conversations = rows; railEl.activeId = activeId ?? undefined; }`,
3523
+ ` });`,
3524
+ ``,
3525
+ ` function onSelect(e: CustomEvent<{ id: string }>) {`,
3526
+ ` activeId = e.detail.id;`,
3527
+ ` }`,
3528
+ ` function onNewChat() {`,
3529
+ ` // A DRAFT, not a record: it becomes a thread on its first turn.`,
3530
+ ` activeId = null;`,
3531
+ ` }`,
3532
+ ]
3533
+ : [
3534
+ ...(hasEmbedded
3535
+ ? sampleSeedComment(isMock, ' ', (literal) => [
3536
+ `let messages = $state.raw<ChatMessage[]>([${literal}]);`,
3537
+ ])
3538
+ : []),
3539
+ ` let messages = $state.raw<ChatMessage[]>([]);`,
3540
+ ];
2494
3541
 
2495
3542
  // SCAF-9: sources element ref + sample data. Typed as the kit's own element
2496
3543
  // interface (not HTMLElement) so the `.sources =` assignment below typechecks
@@ -2554,7 +3601,9 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
2554
3601
  ` <!-- kai-resizable needs kai-resizable-item children to render panels. -->`,
2555
3602
  ` <kai-resizable orientation="horizontal" style="display:block;width:100%;height:100%">`,
2556
3603
  ` <kai-resizable-item size="40%" min="240px">`,
2557
- ` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" onkai-submit={onSubmit}></kai-chat>`,
3604
+ ` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" onkai-submit={onSubmit}>`,
3605
+ ...svelteSlotted(' '),
3606
+ ` </kai-chat>`,
2558
3607
  ` </kai-resizable-item>`,
2559
3608
  ` <kai-resizable-item min="280px">`,
2560
3609
  ` <!-- Replace src with your artifact URL or set .files for multi-file preview. -->`,
@@ -2565,7 +3614,9 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
2565
3614
  companionLines,
2566
3615
  ]
2567
3616
  : [
2568
- ` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" onkai-submit={onSubmit}></kai-chat>`,
3617
+ ` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" onkai-submit={onSubmit}>`,
3618
+ ...svelteSlotted(' '),
3619
+ ` </kai-chat>`,
2569
3620
  companionLines,
2570
3621
  ];
2571
3622
 
@@ -2583,6 +3634,7 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
2583
3634
  // on every archetype without kai-sources.
2584
3635
  ` import type { ${[
2585
3636
  'KaiChatElement',
3637
+ ...(block ? ['KaiConversationsElement'] : []),
2586
3638
  ...(hasSourcesCompanion ? ['KaiSourcesElement'] : []),
2587
3639
  ...(attachments ? ['KaiAttachmentsElement'] : []),
2588
3640
  ].join(', ')} } from '@kitn.ai/ui/elements';`,
@@ -2594,20 +3646,37 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
2594
3646
  cardTools: ctx.cardProvider !== null,
2595
3647
  mock: isMock,
2596
3648
  attachments,
3649
+ workspaceBlock: block,
2597
3650
  }),
2598
3651
  ` import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
2599
3652
  ` import { onMount } from 'svelte';`,
2600
3653
  ...mockInit,
3654
+ ...(block ? [blockThreadStoreLines(' ').join('\n'), ``] : []),
2601
3655
  ` // \`bind:this\` writes to this binding, so under runes it must be $state.`,
2602
3656
  ` let chatEl = $state<KaiChatElement | undefined>(undefined);`,
2603
3657
  ` // SCAF-15: kai-* register via an async dynamic import (SSR-safety). Gate the`,
2604
3658
  ` // property $effect on the upgrade so the first application isn't dropped`,
2605
3659
  ` // (props set on a not-yet-upgraded element are lost on upgrade).`,
2606
3660
  ` let defined = $state(false);`,
2607
- ` onMount(async () => { await customElements.whenDefined('kai-chat'); defined = true; });`,
3661
+ ...(block
3662
+ ? [
3663
+ ` onMount(() => {`,
3664
+ ` void Promise.all([`,
3665
+ ` customElements.whenDefined('kai-chat'),`,
3666
+ ` customElements.whenDefined('kai-conversations'),`,
3667
+ ` ]).then(() => { defined = true; });`,
3668
+ ` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
3669
+ ` const flush = () => saver.flush();`,
3670
+ ` window.addEventListener('beforeunload', flush);`,
3671
+ ` return () => window.removeEventListener('beforeunload', flush);`,
3672
+ ` });`,
3673
+ ]
3674
+ : [
3675
+ ` onMount(async () => { await customElements.whenDefined('kai-chat'); defined = true; });`,
3676
+ ]),
2608
3677
  ...sourcesEl,
2609
3678
  ...sampleMessagesInit,
2610
- ` let loading = $state(false);`,
3679
+ ...(block ? [] : [` let loading = $state(false);`]),
2611
3680
  ` const suggestions: string[] = ${jsArray(suggestions)};`,
2612
3681
  ...modelInit,
2613
3682
  ...cardsInit,
@@ -2629,8 +3698,34 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
2629
3698
  `</script>`,
2630
3699
  ``,
2631
3700
  `<div style="${p.style}">`,
2632
- ...attachmentMarkup,
2633
- ...workspaceMarkup,
3701
+ ...(block
3702
+ ? [
3703
+ ` <!-- ── The workspace BLOCK: the shell + the rail + your thread records ── -->`,
3704
+ ...htmlComment(
3705
+ [
3706
+ `<kai-workspace> is the chat-agnostic layout shell: header/start/main/`,
3707
+ `end/footer slots, aside resize + collapse, and a mobile drawer below`,
3708
+ `the drawer-below width. It knows nothing about chat.`,
3709
+ ],
3710
+ ' ',
3711
+ ),
3712
+ ` <kai-workspace drawer-below="720" style="display:block;width:100%;height:100%">`,
3713
+ ...htmlComment(BLOCK_RAIL_NOTE, ' '),
3714
+ ` <!-- New chat = a DRAFT (activeId null): the record appears on the first turn. -->`,
3715
+ ` <kai-conversations`,
3716
+ ` bind:this={railEl}`,
3717
+ ` slot="start"`,
3718
+ ` style="display:block;height:100%"`,
3719
+ ` onkai-conversation-select={onSelect}`,
3720
+ ` onkai-new-chat={onNewChat}`,
3721
+ ` ></kai-conversations>`,
3722
+ ` <!-- Unnamed children project into the shell's MAIN region. -->`,
3723
+ ` <div style="display:flex;flex-direction:column;height:100%;min-height:0">`,
3724
+ ...indentEmitted([...attachmentMarkup, ...workspaceMarkup], ' '),
3725
+ ` </div>`,
3726
+ ` </kai-workspace>`,
3727
+ ]
3728
+ : [...attachmentMarkup, ...workspaceMarkup]),
2634
3729
  `</div>`,
2635
3730
  ]
2636
3731
  .filter((l, i, arr) => !(l === '' && arr[i - 1] === '' && i === arr.length - 1))
@@ -2677,9 +3772,16 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2677
3772
  // is not needed here — the library is SSR-import-safe, but we include elements for safety)
2678
3773
 
2679
3774
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
2680
- const workspace = isWorkspace(components);
2681
-
2682
- const renderableTags = components.filter((t) => !MESSAGE_EMBEDDED_TAGS.has(t));
3775
+ const workspace = isArtifactSplit(components);
3776
+ const block = isWorkspaceBlock(components);
3777
+
3778
+ // Same union as renderJsx, for the same measured reason: this target renders
3779
+ // `<Chat …>` unconditionally, so the import list cannot come from `components`
3780
+ // alone or a caller who omitted kai-chat gets a file that uses `Chat` without
3781
+ // importing it.
3782
+ const renderableTags = [
3783
+ ...new Set([ALWAYS_EMITTED_TAG, ...components.filter((t) => !MESSAGE_EMBEDDED_TAGS.has(t))]),
3784
+ ];
2683
3785
  const importTags = workspace
2684
3786
  ? [...new Set([...renderableTags.filter((t) => t !== 'kai-resizable'), 'kai-resizable', 'kai-resizable-item'])]
2685
3787
  : renderableTags;
@@ -2692,7 +3794,8 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2692
3794
  (t) =>
2693
3795
  t !== 'kai-chat' &&
2694
3796
  !MESSAGE_EMBEDDED_TAGS.has(t) &&
2695
- !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
3797
+ !ARTIFACT_SPLIT_TAGS.has(t) &&
3798
+ !(block && BLOCK_TAGS.has(t)) &&
2696
3799
  !(attachments && ATTACHMENT_TAGS.has(t)),
2697
3800
  );
2698
3801
 
@@ -2709,6 +3812,12 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2709
3812
  ` {/* Replace sampleSources with your real data. */}`,
2710
3813
  ` <Sources sources={sampleSources} />`,
2711
3814
  );
3815
+ } else if (slotPlacementFor(t)) {
3816
+ // The catalog says where this one goes, and this target cannot put it
3817
+ // there — so it says so, rather than emitting a layout nothing describes.
3818
+ // The placement is PASSED IN, not restated: the note names the parent and
3819
+ // the slot, and both belong to the record.
3820
+ companionJsxLines.push(...railSiblingNote(t, slotPlacementFor(t)!), ` <${toPascalCase(t)} />`);
2712
3821
  } else {
2713
3822
  companionJsxLines.push(` {/* wire data props — see the component_reference MCP tool */}`);
2714
3823
  companionJsxLines.push(` <${toPascalCase(t)} />`);
@@ -2766,16 +3875,63 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2766
3875
 
2767
3876
  const mockInit = isMock ? mockResponderInit() : [];
2768
3877
 
2769
- // SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE.
2770
- const sampleMessagesInit = [
2771
- ...(hasEmbedded
2772
- ? sampleSeedComment(isMock, ' ', (literal) => [
2773
- `const sampleMessages: ChatMessage[] = [${literal}];`,
2774
- `const [messages, setMessages] = useState<ChatMessage[]>(sampleMessages);`,
2775
- ])
2776
- : []),
2777
- ` const [messages, setMessages] = useState<ChatMessage[]>([]);`,
2778
- ].join('\n');
3878
+ // SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE. In BLOCK mode the
3879
+ // thread state is the block's multi-thread store (same shape as renderJsx).
3880
+ const sampleMessagesInit = block
3881
+ ? [
3882
+ ` // ── The block's state: your records + the kit's session/persistence helpers ─`,
3883
+ ` const [threads, setThreads] = useState<Thread[]>(loadThreads);`,
3884
+ ` // null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
3885
+ ` // record is created on its first turn, so the rail never fills with empties.`,
3886
+ ` const [activeId, setActiveId] = useState<string | null>(threads[0]?.id ?? null);`,
3887
+ ` // One id per in-flight reply — a stream keeps running when you switch away,`,
3888
+ ` // so this cannot be a single boolean.`,
3889
+ ` const [streamingIds, setStreamingIds] = useState<string[]>([]);`,
3890
+ ...BLOCK_SESSIONS_NOTE.map((l) => ` // ${l}`),
3891
+ ` const [sessions] = useState(() => createThreadSessions(setStreamingIds));`,
3892
+ ` // The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
3893
+ ` // the save target and what a failed save means are YOURS (see saveThreads).`,
3894
+ ` const [saver] = useState(() => createSaveScheduler(saveThreads, { delayMs: 250 }));`,
3895
+ ` useEffect(() => { saver.schedule(threads); }, [saver, threads]);`,
3896
+ ` useEffect(() => {`,
3897
+ ` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
3898
+ ` const flush = () => saver.flush();`,
3899
+ ` window.addEventListener('beforeunload', flush);`,
3900
+ ` return () => window.removeEventListener('beforeunload', flush);`,
3901
+ ` }, [saver]);`,
3902
+ ``,
3903
+ ` const active = threads.find((t) => t.id === activeId) ?? null;`,
3904
+ ` const messages = active?.messages ?? NO_MESSAGES;`,
3905
+ ` const loading = activeId !== null && streamingIds.includes(activeId);`,
3906
+ ` // The rail's row shape is a VIEW of your record — titles and counts, no`,
3907
+ ` // message bodies. Newest-first is your ordering; the rail renders the array`,
3908
+ ` // in the order it is given.`,
3909
+ ` const rows = threads.map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt }));`,
3910
+ ].join('\n')
3911
+ : [
3912
+ ...(hasEmbedded
3913
+ ? sampleSeedComment(isMock, ' ', (literal) => [
3914
+ `const sampleMessages: ChatMessage[] = [${literal}];`,
3915
+ `const [messages, setMessages] = useState<ChatMessage[]>(sampleMessages);`,
3916
+ ])
3917
+ : []),
3918
+ ` const [messages, setMessages] = useState<ChatMessage[]>([]);`,
3919
+ ].join('\n');
3920
+
3921
+ // The block's module-scope pieces (one joined entry — see renderJsx).
3922
+ const blockModuleInit = block
3923
+ ? [
3924
+ [
3925
+ `/** A stable reference for the empty thread. \`messages\` is diffed by`,
3926
+ ` * reference, so a fresh \`[]\` every render would re-notify for nothing. */`,
3927
+ `const NO_MESSAGES: ChatMessage[] = [];`,
3928
+ ``,
3929
+ ...blockThreadStoreLines(''),
3930
+ ].join('\n'),
3931
+ ]
3932
+ : [];
3933
+
3934
+ const loadingInit = block ? [] : [` const [loading, setLoading] = useState(false);`];
2779
3935
 
2780
3936
  const sampleSourcesInit =
2781
3937
  standaloneCompanionTags.includes('kai-sources')
@@ -2803,20 +3959,66 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2803
3959
  const cardPropsNote = (pad: string): string[] =>
2804
3960
  ctx.emitCards ? jsxComment(CARD_PROP_COMMENT, pad) : [];
2805
3961
 
3962
+ // The block's submit prelude + turn-scoped thread binding — same shape as
3963
+ // renderJsx (this target IS React); see there for the comments' reasoning.
3964
+ const blockPrelude = block
3965
+ ? [
3966
+ ...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
3967
+ `const targetId = activeId ?? crypto.randomUUID();`,
3968
+ `// The record is born on its first turn — with YOUR title policy.`,
3969
+ `setThreads((prev) => prev.some((t) => t.id === targetId)`,
3970
+ ` ? prev`,
3971
+ ` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
3972
+ `setActiveId(targetId);`,
3973
+ ...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
3974
+ `const setMessages = bindThreadMessages<Thread>(setThreads, targetId, {`,
3975
+ ` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
3976
+ `});`,
3977
+ `const controller = sessions.begin(targetId);`,
3978
+ ]
3979
+ : [];
3980
+ const blockReactThread: ThreadBinding = {
3981
+ open: ({ pad, userMessage }) => [
3982
+ `${pad}// THE TURN OWNS THE THREAD (same rule as the single-thread scaffold): the`,
3983
+ `${pad}// loop below re-encodes it every round, and React state cannot be read`,
3984
+ `${pad}// back mid-turn. setMessages (the id-bound sink) just projects it.`,
3985
+ `${pad}let thread: ChatMessage[] = [...(threads.find((t) => t.id === targetId)?.messages ?? []), ${userMessage}];`,
3986
+ `${pad}const set: SetMessages = (fn) => { thread = fn(thread); setMessages(() => thread); };`,
3987
+ `${pad}setMessages(() => thread);`,
3988
+ ],
3989
+ live: 'thread',
3990
+ setter: 'set',
3991
+ };
3992
+
2806
3993
  const onSubmitBody = realStreamBody({
2807
3994
  pad: ' ',
2808
- read: 'messages',
2809
- commitSet: (expr) => `setMessages(${expr});`,
2810
- // useState's setter IS a SetMessages: both are (updater) => void.
3995
+ read: block ? `(threads.find((t) => t.id === targetId)?.messages ?? [])` : 'messages',
3996
+ commitSet: block ? (expr) => `setMessages(() => ${expr});` : (expr) => `setMessages(${expr});`,
3997
+ // useState's setter IS a SetMessages: both are (updater) => void — and in
3998
+ // block mode the id-bound sink from bindThreadMessages is one too.
2811
3999
  setterAdapter: 'setMessages',
2812
- setLoading: (v) => `setLoading(${v});`,
4000
+ setLoading: block
4001
+ ? (v) =>
4002
+ v === 'true'
4003
+ ? `// loading for THIS thread now derives from the sessions map (streamingIds).`
4004
+ : `sessions.end(targetId, controller);`
4005
+ : (v) => `setLoading(${v});`,
2813
4006
  bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
2814
4007
  strictRoles: true,
2815
4008
  toolLoop: emitToolLoop,
2816
4009
  cards: ctx.emitCards,
2817
- thread: REACT_THREAD,
4010
+ thread: block ? blockReactThread : REACT_THREAD,
2818
4011
  mock: isMock,
4012
+ ...(block ? { fetchSignal: 'controller.signal' } : {}),
2819
4013
  ...attachmentSubmitOpts,
4014
+ ...(block
4015
+ ? {
4016
+ afterValue: [
4017
+ ...((attachmentSubmitOpts as { afterValue?: string[] }).afterValue ?? []),
4018
+ ...blockPrelude,
4019
+ ],
4020
+ }
4021
+ : {}),
2820
4022
  });
2821
4023
 
2822
4024
  // File path guidance for TanStack Start (file-based routing)
@@ -2835,11 +4037,72 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2835
4037
  ``,
2836
4038
  ];
2837
4039
 
4040
+ // The surface JSX — same shapes as renderJsx (this target IS React),
4041
+ // including the block's shell wrap.
4042
+ const chatJsx = (pad: string): string[] => [
4043
+ ...cardPropsNote(pad),
4044
+ `${pad}<Chat`,
4045
+ `${pad} messages={messages}`,
4046
+ `${pad} loading={loading}`,
4047
+ `${pad} suggestions={suggestions}`,
4048
+ `${pad} suggestionMode="submit"`,
4049
+ ...cardProps(`${pad} `),
4050
+ `${pad} onSubmit={onSubmit}`,
4051
+ `${pad} style={{ ${jsxStyle(p.chatFill)} }}`,
4052
+ `${pad}/>`,
4053
+ ];
4054
+ const splitJsx: string[] = [
4055
+ ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
4056
+ ` {/* Resizable needs ResizableItem children to render panels. */}`,
4057
+ ` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
4058
+ ` <ResizableItem size="40%" min="240px">`,
4059
+ ...chatJsx(' '),
4060
+ ` </ResizableItem>`,
4061
+ ` <ResizableItem min="280px">`,
4062
+ ` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
4063
+ ` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
4064
+ ` </ResizableItem>`,
4065
+ ` </Resizable>`,
4066
+ ];
4067
+ // Siblings of the SPLIT (or of the chat) — dropping them here is what the old
4068
+ // workspace branch used to do.
4069
+ const mainJsx: string[] = [
4070
+ ...(attachmentJsx ? [attachmentJsx] : []),
4071
+ ...(workspace ? [...splitJsx, companions] : [...chatJsx(' '), companions]),
4072
+ ];
4073
+ const surfaceJsx: string[] = block
4074
+ ? [
4075
+ ` {/* ── The workspace BLOCK: the shell + the rail + your thread records ── */}`,
4076
+ ` {/* <Workspace> is the chat-agnostic layout shell (header/start/main/end/`,
4077
+ ` footer slots, aside resize + collapse, a mobile drawer below`,
4078
+ ` drawerBelow px). It knows nothing about chat. */}`,
4079
+ ` <Workspace drawerBelow={720} style={{ display: 'block', width: '100%', height: '100%' }}>`,
4080
+ ...jsxComment(BLOCK_RAIL_NOTE, ' '),
4081
+ ` {/* The React wrappers forward no slot prop, so a plain div carries the`,
4082
+ ` slot attribute into the shell's start aside. */}`,
4083
+ ` <div slot="start" style={{ height: '100%' }}>`,
4084
+ ` {/* New chat = a DRAFT (activeId null): the record appears on the first turn. */}`,
4085
+ ` <Conversations`,
4086
+ ` conversations={rows}`,
4087
+ ` activeId={activeId ?? undefined}`,
4088
+ ` onConversationSelect={(e) => setActiveId(e.detail.id)}`,
4089
+ ` onNewChat={() => setActiveId(null)}`,
4090
+ ` style={{ display: 'block', height: '100%' }}`,
4091
+ ` />`,
4092
+ ` </div>`,
4093
+ ` {/* Unnamed children project into the shell's MAIN region. */}`,
4094
+ ` <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>`,
4095
+ ...indentEmitted(mainJsx, ' '),
4096
+ ` </div>`,
4097
+ ` </Workspace>`,
4098
+ ]
4099
+ : mainJsx;
4100
+
2838
4101
  return [
2839
4102
  ...filePathNote,
2840
4103
  // TanStack Start uses @tanstack/react-router's createFileRoute
2841
4104
  `import { createFileRoute } from '@tanstack/react-router'`,
2842
- `import { useState } from 'react'`,
4105
+ `import { ${block ? 'useEffect, useState' : 'useState'} } from 'react'`,
2843
4106
  // Elements registration: the library is SSR-import-safe; top-level import is safe here
2844
4107
  `import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
2845
4108
  `import { ${importList} } from '@kitn.ai/ui/react'`,
@@ -2851,6 +4114,7 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2851
4114
  cardTools: ctx.cardProvider !== null,
2852
4115
  mock: isMock,
2853
4116
  attachments,
4117
+ workspaceBlock: block,
2854
4118
  }),
2855
4119
  `import '@kitn.ai/ui/theme.tokens.css' // compiled token defaults`,
2856
4120
  ``,
@@ -2859,6 +4123,7 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2859
4123
  ...mockInit,
2860
4124
  ``,
2861
4125
  ...attachmentModuleInit,
4126
+ ...blockModuleInit,
2862
4127
  ...cardsInit,
2863
4128
  `// ssr: false keeps the Solid-based web component client-only.`,
2864
4129
  `// Server HTML for /chat omits <kai-chat> → no hydration mismatch.`,
@@ -2869,7 +4134,7 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2869
4134
  ``,
2870
4135
  `function ChatPage() {`,
2871
4136
  sampleMessagesInit,
2872
- ` const [loading, setLoading] = useState(false);`,
4137
+ ...loadingInit,
2873
4138
  ` const suggestions = ${jsArray(suggestions)};`,
2874
4139
  ...(attachmentStateInit ? [attachmentStateInit] : []),
2875
4140
  ...(sampleSourcesInit ? [sampleSourcesInit] : []),
@@ -2883,45 +4148,7 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2883
4148
  ``,
2884
4149
  ` return (`,
2885
4150
  ` <main style={{ ${jsxStyle(p.style)} }}>`,
2886
- ...(attachmentJsx ? [attachmentJsx] : []),
2887
- ...(workspace
2888
- ? [
2889
- ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
2890
- ` {/* Resizable needs ResizableItem children to render panels. */}`,
2891
- ` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
2892
- ` <ResizableItem size="40%" min="240px">`,
2893
- ...cardPropsNote(' '),
2894
- ` <Chat`,
2895
- ` messages={messages}`,
2896
- ` loading={loading}`,
2897
- ` suggestions={suggestions}`,
2898
- ` suggestionMode="submit"`,
2899
- ...cardProps(' '),
2900
- ` onSubmit={onSubmit}`,
2901
- ` style={{ ${jsxStyle(p.chatFill)} }}`,
2902
- ` />`,
2903
- ` </ResizableItem>`,
2904
- ` <ResizableItem min="280px">`,
2905
- ` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
2906
- ` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
2907
- ` </ResizableItem>`,
2908
- ` </Resizable>`,
2909
- // Siblings of the SPLIT — see the same line in the other JSX branch.
2910
- companions,
2911
- ]
2912
- : [
2913
- ...cardPropsNote(' '),
2914
- ` <Chat`,
2915
- ` messages={messages}`,
2916
- ` loading={loading}`,
2917
- ` suggestions={suggestions}`,
2918
- ` suggestionMode="submit"`,
2919
- ...cardProps(' '),
2920
- ` onSubmit={onSubmit}`,
2921
- ` style={{ ${jsxStyle(p.chatFill)} }}`,
2922
- ` />`,
2923
- companions,
2924
- ]),
4151
+ ...surfaceJsx,
2925
4152
  ` </main>`,
2926
4153
  ` );`,
2927
4154
  `}`,
@@ -2959,13 +4186,20 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2959
4186
  function renderAngular(components: readonly string[], ctx: RenderCtx): string {
2960
4187
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
2961
4188
 
2962
- const workspace = isWorkspace(components);
4189
+ const workspace = isArtifactSplit(components);
4190
+ const block = isWorkspaceBlock(components);
2963
4191
  const attachments = hasAttachments(components);
4192
+ // Slotted companions are children of <kai-chat> (see `chatTag`), so they leave
4193
+ // the sibling loop the way the workspace and attachment pairs do. In BLOCK
4194
+ // mode the rail lives in the SHELL's start aside instead.
4195
+ const slottedTags = new Set(slottedInChat(components).map((s) => s.tag));
2964
4196
  const standaloneCompanionTags = components.filter(
2965
4197
  (t) =>
2966
4198
  t !== 'kai-chat' &&
2967
4199
  !MESSAGE_EMBEDDED_TAGS.has(t) &&
2968
- !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
4200
+ !ARTIFACT_SPLIT_TAGS.has(t) &&
4201
+ !(block && BLOCK_TAGS.has(t)) &&
4202
+ !slottedTags.has(t) &&
2969
4203
  !(attachments && ATTACHMENT_TAGS.has(t)),
2970
4204
  );
2971
4205
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
@@ -3050,19 +4284,52 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
3050
4284
  const commit = (value: string) => `this.messages.set(${value});`;
3051
4285
  const setter = '(fn) => this.messages.set(fn(this.messages()))';
3052
4286
 
4287
+ // The block's submit prelude — signals instead of refs; same shape.
4288
+ const blockRead = `(this.threads().find((t) => t.id === targetId)?.messages ?? [])`;
4289
+ const blockPrelude = block
4290
+ ? [
4291
+ ...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
4292
+ `const targetId = this.activeId() ?? crypto.randomUUID();`,
4293
+ `// The record is born on its first turn — with YOUR title policy.`,
4294
+ `this.setThreads((prev) => prev.some((t) => t.id === targetId)`,
4295
+ ` ? prev`,
4296
+ ` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
4297
+ `this.activeId.set(targetId);`,
4298
+ ...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
4299
+ `const setMessages = bindThreadMessages<Thread>(this.setThreads, targetId, {`,
4300
+ ` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
4301
+ `});`,
4302
+ `const controller = this.sessions.begin(targetId);`,
4303
+ ]
4304
+ : [];
4305
+
3053
4306
  const onSubmitBody = realStreamBody({
3054
4307
  pad: ' ',
3055
- read,
3056
- commitSet: (expr) => commit(expr),
3057
- setterAdapter: setter,
3058
- setLoading: (v) => `this.loading.set(${v});`,
4308
+ read: block ? blockRead : read,
4309
+ commitSet: block ? (expr) => `setMessages(() => ${expr});` : (expr) => commit(expr),
4310
+ setterAdapter: block ? 'setMessages' : setter,
4311
+ setLoading: block
4312
+ ? (v) =>
4313
+ v === 'true'
4314
+ ? `// loading for THIS thread now derives from the sessions map (streamingIds).`
4315
+ : `this.sessions.end(targetId, controller);`
4316
+ : (v) => `this.loading.set(${v});`,
3059
4317
  bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
3060
4318
  strictRoles: true,
3061
4319
  toolLoop: emitToolLoop,
3062
4320
  cards: ctx.emitCards,
3063
- thread: accessorThreadBinding(read, commit, setter),
4321
+ thread: block ? blockLiveThread(blockRead) : accessorThreadBinding(read, commit, setter),
3064
4322
  mock: isMock,
4323
+ ...(block ? { fetchSignal: 'controller.signal' } : {}),
3065
4324
  ...attachmentSubmitOpts,
4325
+ ...(block
4326
+ ? {
4327
+ afterValue: [
4328
+ ...((attachmentSubmitOpts as { afterValue?: string[] }).afterValue ?? []),
4329
+ ...blockPrelude,
4330
+ ],
4331
+ }
4332
+ : {}),
3066
4333
  });
3067
4334
 
3068
4335
  // Module scope, exactly like vue: a class can hold neither a bare `const` nor a
@@ -3091,15 +4358,43 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
3091
4358
  ]
3092
4359
  : [];
3093
4360
 
3094
- // SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE.
3095
- const sampleSeed = [
3096
- ...(hasEmbedded
3097
- ? sampleSeedComment(isMock, ' ', (literal) => [
3098
- `readonly messages = signal<ChatMessage[]>([${literal}]);`,
3099
- ])
3100
- : []),
3101
- ` readonly messages = signal<ChatMessage[]>([]);`,
3102
- ];
4361
+ // SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE. In BLOCK mode the
4362
+ // thread state is the block's multi-thread store; `messages`/`loading` are
4363
+ // computed views of it.
4364
+ const sampleSeed = block
4365
+ ? [
4366
+ ` // ── The block's state: your records + the kit's session/persistence helpers ─`,
4367
+ ` readonly threads = signal<Thread[]>(loadThreads());`,
4368
+ ` // null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
4369
+ ` // record is created on its first turn, so the rail never fills with empties.`,
4370
+ ` readonly activeId = signal<string | null>(this.threads()[0]?.id ?? null);`,
4371
+ ` // One id per in-flight reply — a stream keeps running when you switch away.`,
4372
+ ` readonly streamingIds = signal<string[]>([]);`,
4373
+ ...BLOCK_SESSIONS_NOTE.map((l) => ` // ${l}`),
4374
+ ` private readonly sessions = createThreadSessions((ids) => this.streamingIds.set(ids));`,
4375
+ ` // The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
4376
+ ` // the save target and what a failed save means are YOURS (see saveThreads).`,
4377
+ ` private readonly saver = createSaveScheduler(saveThreads, { delayMs: 250 });`,
4378
+ ` // Every write goes through this — a NEW array per write is what notifies.`,
4379
+ ` private readonly setThreads = (updater: (prev: Thread[]) => Thread[]): void =>`,
4380
+ ` this.threads.update(updater);`,
4381
+ ` readonly active = computed(() => this.threads().find((t) => t.id === this.activeId()) ?? null);`,
4382
+ ` readonly messages = computed(() => this.active()?.messages ?? []);`,
4383
+ ` readonly loading = computed(() => { const id = this.activeId(); return id !== null && this.streamingIds().includes(id); });`,
4384
+ ` // The rail's row shape is a VIEW of your record — titles and counts, no`,
4385
+ ` // message bodies. The rail renders the array in the order it is given.`,
4386
+ ` readonly rows = computed(() => this.threads().map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt })));`,
4387
+ ` // The template cannot spell \`undefined\` portably, so the ?? lives here.`,
4388
+ ` readonly activeIdOrUndefined = computed(() => this.activeId() ?? undefined);`,
4389
+ ]
4390
+ : [
4391
+ ...(hasEmbedded
4392
+ ? sampleSeedComment(isMock, ' ', (literal) => [
4393
+ `readonly messages = signal<ChatMessage[]>([${literal}]);`,
4394
+ ])
4395
+ : []),
4396
+ ` readonly messages = signal<ChatMessage[]>([]);`,
4397
+ ];
3103
4398
 
3104
4399
  const sourcesField = hasSourcesCompanion
3105
4400
  ? [
@@ -3115,8 +4410,9 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
3115
4410
  ? [` Object.assign(this.sourcesEl().nativeElement, { sources: this.sampleSources });`]
3116
4411
  : [];
3117
4412
 
3118
- const chatTag = (pad: string) =>
3119
- [
4413
+ const chatTag = (pad: string) => {
4414
+ const slotted = block ? [] : slottedChildMarkup(components, ' ', { wired: false, ids: false });
4415
+ return [
3120
4416
  `<kai-chat`,
3121
4417
  ` #chat`,
3122
4418
  ` [messages]="messages()"`,
@@ -3125,8 +4421,9 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
3125
4421
  ` suggestion-mode="submit"`,
3126
4422
  ` style="${p.chatFill}"`,
3127
4423
  ` (kai-submit)="onSubmit($event)"`,
3128
- `></kai-chat>`,
4424
+ ...(slotted.length === 0 ? [`></kai-chat>`] : [`>`, ...slotted, `</kai-chat>`]),
3129
4425
  ].map((l) => `${pad}${l}`);
4426
+ };
3130
4427
 
3131
4428
  const templateBody = workspace
3132
4429
  ? [
@@ -3149,11 +4446,48 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
3149
4446
  // KaiSourcesElement is imported only when a kai-sources companion is really
3150
4447
  // declared: an always-on import is unused on every other archetype, and a stock
3151
4448
  // Angular tsconfig turns on the checks that make that a build error.
3152
- const elementTypes = hasSourcesCompanion ? 'KaiChatElement, KaiSourcesElement' : 'KaiChatElement';
4449
+ const elementTypes = [
4450
+ 'KaiChatElement',
4451
+ ...(block ? ['KaiConversationsElement'] : []),
4452
+ ...(hasSourcesCompanion ? ['KaiSourcesElement'] : []),
4453
+ ].join(', ');
3153
4454
  // No KaiAttachmentsElement here: unlike svelte/html this target never holds an
3154
4455
  // element reference for the list — `[items]` is a template binding, so nothing
3155
4456
  // in the class is typed by it.
3156
4457
 
4458
+ // The BLOCK's template wrap: the shell around the whole surface, the rail in
4459
+ // its start aside, the rest projected into the main region.
4460
+ const surfaceTemplate = block
4461
+ ? [
4462
+ ` <!-- ── The workspace BLOCK: the shell + the rail + your thread records ── -->`,
4463
+ ...htmlComment(
4464
+ [
4465
+ `<kai-workspace> is the chat-agnostic layout shell: header/start/main/`,
4466
+ `end/footer slots, aside resize + collapse, and a mobile drawer below`,
4467
+ `the drawer-below width. It knows nothing about chat.`,
4468
+ ],
4469
+ ' ',
4470
+ ),
4471
+ ` <kai-workspace drawer-below="720" style="display:block;width:100%;height:100%">`,
4472
+ ...htmlComment(BLOCK_RAIL_NOTE, ' '),
4473
+ ` <!-- New chat = a DRAFT (activeId null): the record appears on the first turn. -->`,
4474
+ ` <kai-conversations`,
4475
+ ` #rail`,
4476
+ ` slot="start"`,
4477
+ ` [conversations]="rows()"`,
4478
+ ` [activeId]="activeIdOrUndefined()"`,
4479
+ ` style="display:block;height:100%"`,
4480
+ ` (kai-conversation-select)="onSelect($event)"`,
4481
+ ` (kai-new-chat)="onNewChat()"`,
4482
+ ` ></kai-conversations>`,
4483
+ ` <!-- Unnamed children project into the shell's MAIN region. -->`,
4484
+ ` <div style="display:flex;flex-direction:column;height:100%;min-height:0">`,
4485
+ ...indentEmitted([...attachmentTemplate, ...templateBody], ' '),
4486
+ ` </div>`,
4487
+ ` </kai-workspace>`,
4488
+ ]
4489
+ : [...attachmentTemplate, ...templateBody];
4490
+
3157
4491
  return [
3158
4492
  `// Angular standalone component — save as: src/app/chat.component.ts`,
3159
4493
  `// Render it: put <app-chat /> in your root template and add ChatComponent to`,
@@ -3165,7 +4499,7 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
3165
4499
  `// "styles": ["node_modules/@kitn.ai/ui/dist/theme.tokens.css", "src/styles.css"]`,
3166
4500
  `// (@kitn.ai/ui/theme.tokens.css is the compiled token file; theme.css is`,
3167
4501
  `// Tailwind source and is only for apps that compile Tailwind themselves.)`,
3168
- `import { CUSTOM_ELEMENTS_SCHEMA, Component, ElementRef, afterNextRender, signal, viewChild } from '@angular/core';`,
4502
+ `import { CUSTOM_ELEMENTS_SCHEMA, Component, ElementRef, afterNextRender, ${block ? 'computed, effect, ' : ''}signal, viewChild } from '@angular/core';`,
3169
4503
  `import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
3170
4504
  `import type { ${elementTypes} } from '@kitn.ai/ui/elements';`,
3171
4505
  ...wireImportLines({
@@ -3175,6 +4509,7 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
3175
4509
  cardTools: ctx.cardProvider !== null,
3176
4510
  mock: isMock,
3177
4511
  attachments,
4512
+ workspaceBlock: block,
3178
4513
  }),
3179
4514
  ``,
3180
4515
  `// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
@@ -3183,6 +4518,7 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
3183
4518
  ``,
3184
4519
  ...modelInit,
3185
4520
  ...attachmentModuleInit,
4521
+ ...(block ? [...blockThreadStoreLines(''), ``] : []),
3186
4522
  ...cardsInit,
3187
4523
  ...toolsLines,
3188
4524
  ...runnerLines,
@@ -3194,8 +4530,7 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
3194
4530
  ` schemas: [CUSTOM_ELEMENTS_SCHEMA],`,
3195
4531
  ` template: \``,
3196
4532
  ` <div style="${p.style}">`,
3197
- ...attachmentTemplate,
3198
- ...templateBody,
4533
+ ...surfaceTemplate,
3199
4534
  ` </div>`,
3200
4535
  ` \`,`,
3201
4536
  `})`,
@@ -3203,12 +4538,21 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
3203
4538
  ` // Every write assigns a NEW array. That reference change is what re-renders`,
3204
4539
  ` // <kai-chat> — mutating the array in place does nothing.`,
3205
4540
  ...sampleSeed,
3206
- ` readonly loading = signal(false);`,
4541
+ ...(block ? [] : [` readonly loading = signal(false);`]),
3207
4542
  ` readonly suggestions = ${jsArray(suggestions)};`,
3208
4543
  ...sourcesField,
3209
4544
  ` private readonly chatEl = viewChild.required<ElementRef<KaiChatElement>>('chat');`,
4545
+ ...(block
4546
+ ? [` private readonly railEl = viewChild.required<ElementRef<KaiConversationsElement>>('rail');`]
4547
+ : []),
3210
4548
  ``,
3211
4549
  ` constructor() {`,
4550
+ ...(block
4551
+ ? [
4552
+ ` // The save scheduler rides an effect: every threads() write schedules a save.`,
4553
+ ` effect(() => this.saver.schedule(this.threads()));`,
4554
+ ]
4555
+ : []),
3212
4556
  ` // SCAF-15: kai-* register via an async dynamic import (SSR-safety), so the`,
3213
4557
  ` // element may not be upgraded when Angular first applies the bindings above —`,
3214
4558
  ` // and a property set on a not-yet-upgraded element is dropped on upgrade.`,
@@ -3223,10 +4567,34 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
3223
4567
  ` suggestions: this.suggestions,`,
3224
4568
  ...cardPropAssign,
3225
4569
  ` });`,
4570
+ ...(block
4571
+ ? [
4572
+ ` // The rail's array props follow the same upgrade rule.`,
4573
+ ` await customElements.whenDefined('kai-conversations');`,
4574
+ ` Object.assign(this.railEl().nativeElement, {`,
4575
+ ` conversations: this.rows(),`,
4576
+ ` activeId: this.activeIdOrUndefined(),`,
4577
+ ` });`,
4578
+ ` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
4579
+ ` window.addEventListener('beforeunload', () => this.saver.flush());`,
4580
+ ]
4581
+ : []),
3226
4582
  ...sourcesReapply,
3227
4583
  ` });`,
3228
4584
  ` }`,
3229
4585
  ``,
4586
+ ...(block
4587
+ ? [
4588
+ ` onSelect(event: Event) {`,
4589
+ ` this.activeId.set((event as CustomEvent<{ id: string }>).detail.id);`,
4590
+ ` }`,
4591
+ ` onNewChat() {`,
4592
+ ` // A DRAFT, not a record: it becomes a thread on its first turn.`,
4593
+ ` this.activeId.set(null);`,
4594
+ ` }`,
4595
+ ``,
4596
+ ]
4597
+ : []),
3230
4598
  ...attachmentFields,
3231
4599
  ``,
3232
4600
  ` // \`Event\`, not \`CustomEvent\`: under strictTemplates Angular types \`$event\` on`,
@@ -3287,17 +4655,25 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
3287
4655
  function renderSolid(components: readonly string[], ctx: RenderCtx): string {
3288
4656
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
3289
4657
 
3290
- const workspace = isWorkspace(components);
4658
+ const workspace = isArtifactSplit(components);
4659
+ const block = isWorkspaceBlock(components);
3291
4660
  const attachments = hasAttachments(components);
3292
4661
  const standaloneCompanionTags = components.filter(
3293
4662
  (t) =>
3294
4663
  t !== 'kai-chat' &&
3295
4664
  !MESSAGE_EMBEDDED_TAGS.has(t) &&
3296
- !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
4665
+ !ARTIFACT_SPLIT_TAGS.has(t) &&
4666
+ !(block && BLOCK_TAGS.has(t)) &&
3297
4667
  !(attachments && ATTACHMENT_TAGS.has(t)),
3298
4668
  );
3299
4669
  const hasSources = standaloneCompanionTags.includes('kai-sources');
3300
4670
  const hasVoice = standaloneCompanionTags.includes('kai-voice-input');
4671
+ // The two above are the only companions this renderer draws. Anything else in
4672
+ // the list reaches the emitted file as nothing at all, so it is named in the
4673
+ // header rather than dropped in silence.
4674
+ const unrenderedCompanions = standaloneCompanionTags.filter(
4675
+ (t) => t !== 'kai-sources' && t !== 'kai-voice-input',
4676
+ );
3301
4677
 
3302
4678
  // Every name here is referenced by the emitted tree below — `noUnusedLocals` is
3303
4679
  // on in a stock Solid app (`npm run build` runs `tsc` first), so an extra one
@@ -3336,6 +4712,9 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
3336
4712
  'SourceTrigger',
3337
4713
  'Tool',
3338
4714
  ...(workspace ? ['Artifact', 'ResizableHandle', 'ResizablePanel', 'ResizablePanelGroup'] : []),
4715
+ // The BLOCK's structural pair: the Solid shell + the Solid rail (this target
4716
+ // renders components, not <kai-*> tags — same rule as everything above).
4717
+ ...(block ? ['ConversationList', 'WorkspaceShell'] : []),
3339
4718
  ...(hasVoice ? ['VoiceInput'] : []),
3340
4719
  // `Attachments`/`Attachment`/`AttachmentPreview`/`AttachmentInfo` are already
3341
4720
  // unconditional above — `renderPart` draws `file` PARTS with them whatever the
@@ -3353,17 +4732,43 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
3353
4732
  // exactly the `SetMessages` shape createAssistantStream wants.
3354
4733
  const setter = '(fn) => setMessages((prev) => fn(prev))';
3355
4734
 
4735
+ // The block's submit prelude — signals; same shape as every other target.
4736
+ // `setThreads` is Solid's own setter: its function-argument overload IS the
4737
+ // SetThreads updater shape bindThreadMessages wants.
4738
+ const blockRead = `(threads().find((t) => t.id === targetId)?.messages ?? [])`;
4739
+ const blockPrelude = block
4740
+ ? [
4741
+ ...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
4742
+ `const targetId = activeId() ?? crypto.randomUUID();`,
4743
+ `// The record is born on its first turn — with YOUR title policy.`,
4744
+ `setThreads((prev) => prev.some((t) => t.id === targetId)`,
4745
+ ` ? prev`,
4746
+ ` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
4747
+ `setActiveId(targetId);`,
4748
+ ...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
4749
+ `const setMessages = bindThreadMessages<Thread>((fn) => { setThreads((prev) => fn(prev)); }, targetId, {`,
4750
+ ` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
4751
+ `});`,
4752
+ `const controller = sessions.begin(targetId);`,
4753
+ ]
4754
+ : [];
4755
+
3356
4756
  const onSubmitBody = realStreamBody({
3357
4757
  pad: ' ',
3358
- read,
3359
- commitSet: (expr) => commit(expr),
3360
- setterAdapter: setter,
3361
- setLoading: (v) => `setLoading(${v});`,
4758
+ read: block ? blockRead : read,
4759
+ commitSet: block ? (expr) => `setMessages(() => ${expr});` : (expr) => commit(expr),
4760
+ setterAdapter: block ? 'setMessages' : setter,
4761
+ setLoading: block
4762
+ ? (v) =>
4763
+ v === 'true'
4764
+ ? `// loading for THIS thread now derives from the sessions map (streamingIds).`
4765
+ : `sessions.end(targetId, controller);`
4766
+ : (v) => `setLoading(${v});`,
3362
4767
  bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
3363
4768
  strictRoles: true,
3364
4769
  toolLoop: emitToolLoop,
3365
4770
  cards: ctx.emitCards,
3366
- thread: accessorThreadBinding(read, commit, setter),
4771
+ thread: block ? blockLiveThread(blockRead) : accessorThreadBinding(read, commit, setter),
3367
4772
  valueSource: 'input()',
3368
4773
  // The staged files are captured BEFORE either list is cleared. `setInput('')`
3369
4774
  // stays last so the order reads the way it executes.
@@ -3375,9 +4780,11 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
3375
4780
  fromSubmitEvent: false,
3376
4781
  })
3377
4782
  : []),
4783
+ ...blockPrelude,
3378
4784
  `setInput('');`,
3379
4785
  ],
3380
4786
  ...(attachments ? { filesExpr: 'files' } : {}),
4787
+ ...(block ? { fetchSignal: 'controller.signal' } : {}),
3381
4788
  mock: isMock,
3382
4789
  });
3383
4790
 
@@ -3593,6 +5000,23 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
3593
5000
  return [
3594
5001
  `// SolidJS + Vite — save as: src/App.tsx`,
3595
5002
  `//`,
5003
+ // What this target DROPS, said out loud. It renders Solid components for the
5004
+ // companions it knows (sources, voice input) and nothing at all for the rest,
5005
+ // so a `components` list naming one of those used to produce a file with no
5006
+ // trace of it — a silent drop, and the quiet half of the same defect the
5007
+ // conversation rail had. Naming them is not a fix; it is the honest record
5008
+ // until this target grows a branch for them.
5009
+ ...(unrenderedCompanions.length > 0
5010
+ ? [
5011
+ `// NOT WIRED, and not rendered: ${unrenderedCompanions.join(', ')}.`,
5012
+ `// You asked for ${unrenderedCompanions.length === 1 ? 'it' : 'them'} and this target has no branch for`,
5013
+ `// ${unrenderedCompanions.length === 1 ? 'it' : 'them'} — see the note below on why this file renders Solid`,
5014
+ `// components rather than <kai-*> elements. Compose the Solid component`,
5015
+ `// yourself (ask the component_reference MCP tool for what it exports), or`,
5016
+ `// scaffold the html target, which drives the custom elements directly.`,
5017
+ `//`,
5018
+ ]
5019
+ : []),
3596
5020
  `// This target does NOT use the <kai-*> custom elements, and that is deliberate:`,
3597
5021
  `// the kit is AUTHORED in SolidJS, so a Solid app renders the real components`,
3598
5022
  `// with real props and real fine-grained reactivity. Going through the`,
@@ -3609,7 +5033,7 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
3609
5033
  `// The @source line is NOT optional: without it Tailwind scans only src/, strips`,
3610
5034
  `// every kit utility class as unused, and the whole UI renders unstyled.`,
3611
5035
  `// (theme.css here, not theme.tokens.css: this app compiles Tailwind itself.)`,
3612
- `import { For, Index, Match, Show, Switch, createMemo, createSignal } from 'solid-js';`,
5036
+ `import { For, Index, Match, Show, Switch, ${block ? 'createEffect, ' : ''}createMemo, createSignal${block ? ', onCleanup' : ''} } from 'solid-js';`,
3613
5037
  // '@kitn.ai/ui/solid', NOT the root '@kitn.ai/ui', and the difference is
3614
5038
  // invisible to every compiler on this repo's critical path: src/solid.ts is
3615
5039
  // `export * from './index'`, so ./solid is a strict SUPERSET of the root and
@@ -3637,12 +5061,14 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
3637
5061
  cards: ctx.emitCards,
3638
5062
  cardTools: ctx.cardProvider !== null,
3639
5063
  mock: isMock,
5064
+ workspaceBlock: block,
3640
5065
  }),
3641
5066
  ``,
3642
5067
  `// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
3643
5068
  ...(p.altNote ?? []).map((l) => `// ${l}`),
3644
5069
  ``,
3645
5070
  ...(isMock ? [...mockResponderInit(), ``] : []),
5071
+ ...(block ? [...blockThreadStoreLines(''), ``] : []),
3646
5072
  ...cardsInit,
3647
5073
  `// Narrow a part to ONE variant, or false. One read, one cast — and the JSX`,
3648
5074
  `// below re-runs it on every delta, which is what keeps a growing text or`,
@@ -3820,8 +5246,36 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
3820
5246
  ` // streaming message as a NEW OBJECT on every delta: a new reference IS the`,
3821
5247
  ` // re-render signal. Which is exactly why the thread below is keyed by message`,
3822
5248
  ` // id rather than by the message objects — see messageKeys.`,
3823
- ` const [messages, setMessages] = createSignal<ChatMessage[]>([]);`,
3824
- ` const [loading, setLoading] = createSignal(false);`,
5249
+ ...(block
5250
+ ? [
5251
+ ` // ── The block's state: your records + the kit's session/persistence helpers ─`,
5252
+ ` const [threads, setThreads] = createSignal<Thread[]>(loadThreads());`,
5253
+ ` // null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
5254
+ ` // record is created on its first turn, so the rail never fills with empties.`,
5255
+ ` const [activeId, setActiveId] = createSignal<string | null>(threads()[0]?.id ?? null);`,
5256
+ ` // One id per in-flight reply — a stream keeps running when you switch away.`,
5257
+ ` const [streamingIds, setStreamingIds] = createSignal<string[]>([]);`,
5258
+ ...BLOCK_SESSIONS_NOTE.map((l) => ` // ${l}`),
5259
+ ` const sessions = createThreadSessions((ids) => setStreamingIds(ids));`,
5260
+ ` // The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
5261
+ ` // the save target and what a failed save means are YOURS (see saveThreads).`,
5262
+ ` const saver = createSaveScheduler(saveThreads, { delayMs: 250 });`,
5263
+ ` createEffect(() => saver.schedule(threads()));`,
5264
+ ` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
5265
+ ` const flushOnUnload = () => saver.flush();`,
5266
+ ` window.addEventListener('beforeunload', flushOnUnload);`,
5267
+ ` onCleanup(() => window.removeEventListener('beforeunload', flushOnUnload));`,
5268
+ ` const active = createMemo(() => threads().find((t) => t.id === activeId()) ?? null);`,
5269
+ ` const messages = createMemo(() => active()?.messages ?? []);`,
5270
+ ` const loading = createMemo(() => { const id = activeId(); return id !== null && streamingIds().includes(id); });`,
5271
+ ` // The rail's row shape is a VIEW of your record — titles and counts, no`,
5272
+ ` // message bodies. The rail renders the array in the order it is given.`,
5273
+ ` const rows = createMemo(() => threads().map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt })));`,
5274
+ ]
5275
+ : [
5276
+ ` const [messages, setMessages] = createSignal<ChatMessage[]>([]);`,
5277
+ ` const [loading, setLoading] = createSignal(false);`,
5278
+ ]),
3825
5279
  ` // PromptInput is CONTROLLED here, so this signal — not a kai-submit event — is`,
3826
5280
  ` // where the submitted text comes from.`,
3827
5281
  ` const [input, setInput] = createSignal('');`,
@@ -3849,7 +5303,33 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
3849
5303
  ` // component below it. Mount it once, at the top.`,
3850
5304
  ` <ChatConfig>`,
3851
5305
  ` <div style={{ ${solidStyle(p.style)} }}>`,
3852
- ...tree,
5306
+ ...(block
5307
+ ? [
5308
+ ` {/* ── The workspace BLOCK: the shell + the rail + your thread records ── */}`,
5309
+ ` {/* WorkspaceShell is the chat-agnostic layout shell (header/start/main/`,
5310
+ ` end/footer regions, aside resize + collapse, a mobile drawer below`,
5311
+ ` drawerBelow px). It knows nothing about chat. */}`,
5312
+ ` <WorkspaceShell`,
5313
+ ` drawerBelow={720}`,
5314
+ ` class="h-full w-full"`,
5315
+ ` start={`,
5316
+ // A JS line comment, not a {/* */} pair: inside an attribute's
5317
+ // expression container the braces form is a parse error.
5318
+ ...BLOCK_RAIL_NOTE_SOLID.map((l) => ` // ${l}`),
5319
+ ` <ConversationList`,
5320
+ ` groups={[]}`,
5321
+ ` conversations={rows()}`,
5322
+ ` activeId={activeId() ?? undefined}`,
5323
+ ` onSelect={(id) => setActiveId(id)}`,
5324
+ ` onNewChat={() => setActiveId(null)}`,
5325
+ ` class="h-full"`,
5326
+ ` />`,
5327
+ ` }`,
5328
+ ` >`,
5329
+ ...indentEmitted(tree, ' '),
5330
+ ` </WorkspaceShell>`,
5331
+ ]
5332
+ : tree),
3853
5333
  ` </div>`,
3854
5334
  ` </ChatConfig>`,
3855
5335
  ` );`,
@@ -4169,17 +5649,38 @@ function viteMiddlewareAdapter(plugin: string): WebRouteAdapter {
4169
5649
  ` name: 'chat-api',`,
4170
5650
  ` configureServer(server) {`,
4171
5651
  ` server.middlewares.use('/api/chat', async (req, res) => {`,
5652
+ ` // The METHOD has to survive the bridge, like the status does below.`,
5653
+ ` // Hard-coding 'POST' here meant a bare GET /api/chat arrived at`,
5654
+ ` // readChatRequest as an empty POST and came back 400 "not valid JSON":`,
5655
+ ` // the 405 guard was emitted, compiled, and unreachable on this host.`,
5656
+ ` const method = req.method ?? 'POST';`,
5657
+ ` // undici THROWS on \`new Request(url, { method: 'GET', body })\`, so only`,
5658
+ ` // read a body from the methods that can carry one.`,
5659
+ ` const hasBody = method !== 'GET' && method !== 'HEAD';`,
4172
5660
  ` let body = '';`,
4173
- ` req.setEncoding('utf8');`,
4174
- ` for await (const chunk of req) body += chunk;`,
5661
+ ` if (hasBody) {`,
5662
+ ` req.setEncoding('utf8');`,
5663
+ ` for await (const chunk of req) body += chunk;`,
5664
+ ` }`,
4175
5665
  ``,
4176
- ` const response = await chatHandler(`,
4177
- ` new Request('http://localhost/api/chat', {`,
4178
- ` method: 'POST',`,
4179
- ` headers: { 'Content-Type': 'application/json' },`,
4180
- ` body,`,
4181
- ` }),`,
4182
- ` );`,
5666
+ ` let response: Response;`,
5667
+ ` try {`,
5668
+ ` response = await chatHandler(`,
5669
+ ` new Request('http://localhost/api/chat', {`,
5670
+ ` method,`,
5671
+ ` headers: { 'Content-Type': 'application/json' },`,
5672
+ ` body: hasBody ? body : undefined,`,
5673
+ ` }),`,
5674
+ ` );`,
5675
+ ` } catch (error) {`,
5676
+ ` // An unhandled rejection in an async connect middleware EXITS Node 22 —`,
5677
+ ` // this catch is the guard findings F-10 exists for. The browser gets`,
5678
+ ` // eight generic words, so the TERMINAL has to get the real thing:`,
5679
+ ` // swallowing it here would make a dev server strictly less`,
5680
+ ` // diagnosable than the crash it replaced.`,
5681
+ ` console.error('[chat-api] chatHandler failed:', error);`,
5682
+ ` response = Response.json({ error: 'Chat handler failed.' }, { status: 500 });`,
5683
+ ` }`,
4183
5684
  ``,
4184
5685
  ` // The STATUS has to survive the bridge: a 401 from the provider that`,
4185
5686
  ` // arrives at the browser as a 200 is a blank bubble and no error.`,
@@ -4553,7 +6054,15 @@ function cannotHostWarning(integration: Integration, route: RouteChoice, framewo
4553
6054
  // and reaches localhost, so the page can call it. That advice was emitted
4554
6055
  // identically for every integration because nothing read the flag that
4555
6056
  // distinguishes them.
4556
- ...(integration.keyExposure === 'frontend-safe'
6057
+ ...(integration.id === 'mock'
6058
+ ? [
6059
+ // For mock the honest first option is to do nothing: the block is
6060
+ // already labelled OPTIONAL, and "call it directly" would be advice
6061
+ // about an upstream that does not exist.
6062
+ `# • or ignore it: the front end above already streams the mock locally, so`,
6063
+ `# nothing in this scaffold needs the route to run.`,
6064
+ ]
6065
+ : integration.keyExposure === 'frontend-safe'
4557
6066
  ? [
4558
6067
  `# • or drop the route entirely: this integration needs no server hop — see the`,
4559
6068
  `# run note — so the page may call it directly (CORS applies).`,
@@ -4566,7 +6075,10 @@ function cannotHostWarning(integration: Integration, route: RouteChoice, framewo
4566
6075
  `# /api/chat to it, or point the fetch at http://localhost:8000/api/chat.`,
4567
6076
  ]
4568
6077
  : [`# • or run it where it belongs: framework: "${route.framework}".`]),
4569
- `# • or use integration: "mock" for a zero-config local stream (no backend, no key).`,
6078
+ // Not emitted for mock itself: "use mock" is where the reader already is.
6079
+ ...(integration.id === 'mock'
6080
+ ? []
6081
+ : [`# • or use integration: "mock" for a zero-config local stream (no backend, no key).`]),
4570
6082
  ];
4571
6083
  return [
4572
6084
  `#`,
@@ -4787,7 +6299,11 @@ function compose(
4787
6299
  audience,
4788
6300
  });
4789
6301
  const isMock = integration.id === 'mock';
4790
- const route = isMock ? undefined : chooseRoute(integration, framework);
6302
+ // `mock` takes the same path as every real integration (G-04): its webRoute
6303
+ // streams createMockResponder() frames and the adapters wrap it per framework.
6304
+ // What stays special is the CLAIM, made in the preface below: the front end
6305
+ // streams locally, so this is the one block (2) nothing in the app requires.
6306
+ const route = chooseRoute(integration, framework);
4791
6307
 
4792
6308
  const header = [
4793
6309
  `# AI/UI scaffold — ${preset ? preset.title : surfaceLabel(components)} × ${integration.title}`,
@@ -4803,13 +6319,24 @@ function compose(
4803
6319
 
4804
6320
  const block2Parts: string[] = [`=== (2) BACKEND ROUTE ===`, ``];
4805
6321
  if (isMock) {
4806
- // The mock integration has no backend the front-end streams locally.
6322
+ // The claim that keeps the zero-config story honest, made BEFORE the code:
6323
+ // the front end above never fetches, so this route is the one block (2)
6324
+ // nothing in the emitted app requires. It exists because two clean-room
6325
+ // builds in a row (rung-2 finding G-04) were asked for "a local dev endpoint
6326
+ // that streams a mocked response" and found prose here where every real
6327
+ // integration ships code.
4807
6328
  block2Parts.push(
4808
- `# No backend or API key needed replies stream locally for preview (see the`,
4809
- `# front-end onSubmit above). Swap \`integration\` for a real provider (openrouter,`,
4810
- `# ollama, vercel-ai-sdk, ) when ready, and this block becomes its route handler.`,
6329
+ `# OPTIONAL the front end above streams its reply locally, so this scaffold`,
6330
+ `# RUNS with no backend and no key. The route below is the mock's server half:`,
6331
+ `# the same createMockResponder() frames served over HTTP, so you can stand up`,
6332
+ `# the real /api/chat seam before any provider exists. To use it, swap the`,
6333
+ `# onSubmit's \`const res = mockResponse(value);\` for a request to /api/chat and`,
6334
+ `# hand its response body to the same reader. Going live later then means`,
6335
+ `# editing THIS file only — replace the responder with a provider call, or`,
6336
+ `# re-scaffold with a real integration and this block becomes its route.`,
4811
6337
  );
4812
- } else if (route) {
6338
+ }
6339
+ if (route) {
4813
6340
  if (!route.exact) {
4814
6341
  block2Parts.push(
4815
6342
  `# Note: ${integration.title} has no route for "${framework}". Emitting its native`,
@@ -5070,8 +6597,8 @@ function rejectUseCase(id: string): string {
5070
6597
  // so the rejection that teaches the id list is the right place to say so.
5071
6598
  `These are PRESETS over the real axis, which is \`components\`. To compose a surface no`,
5072
6599
  `preset names, pass the list directly, e.g. components: ["kai-chat", "kai-tool",`,
5073
- `"kai-reasoning", "kai-artifact", "kai-resizable"] for a workspace that also renders its`,
5074
- `tool calls. Pick a preset id or pass \`components\`, then call scaffold again.`,
6600
+ `"kai-reasoning", "kai-artifact", "kai-resizable"] for an artifact split that also renders`,
6601
+ `its tool calls. Pick a preset id or pass \`components\`, then call scaffold again.`,
5075
6602
  ].join('\n');
5076
6603
  }
5077
6604