@kitn.ai/ui 0.21.0 → 0.22.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 (243) hide show
  1. package/dist/components/chat-thread.d.ts +9 -0
  2. package/dist/{create-tween-D3Y5qiij.js → create-tween-COnPwAQN.js} +1 -1
  3. package/dist/{create-tween-DGKY1FDY.js → create-tween-DmTUaXWE.js} +1 -1
  4. package/dist/{create-tween-paho5Rh-.js → create-tween-qs-jjvNA.js} +1 -1
  5. package/dist/custom-elements.json +43 -7
  6. package/dist/elements/agent-card.js +1 -1
  7. package/dist/elements/artifact.js +1 -1
  8. package/dist/elements/attachments.js +1 -1
  9. package/dist/elements/audio-visualizer.js +1 -1
  10. package/dist/elements/avatar.js +1 -1
  11. package/dist/elements/badge.js +1 -1
  12. package/dist/elements/button.js +1 -1
  13. package/dist/elements/card.js +1 -1
  14. package/dist/elements/cards.js +1 -1
  15. package/dist/elements/chain-of-thought.js +1 -1
  16. package/dist/elements/chat-scope-picker.js +1 -1
  17. package/dist/elements/chat-workspace.js +1 -1
  18. package/dist/elements/chat.js +1 -1
  19. package/dist/elements/checkpoint.js +1 -1
  20. package/dist/elements/choice.js +1 -1
  21. package/dist/elements/chunks/{Icon-C-2LvGDG.js → Icon-CHG5z5L7.js} +1 -1
  22. package/dist/elements/chunks/{action-icons-CaYoImEk.js → action-icons-BAtCEEgg.js} +1 -1
  23. package/dist/elements/chunks/arrow-left-CFPTeL1Y.js +1 -0
  24. package/dist/elements/chunks/{artifact-BBIscOxJ.js → artifact-fBZOE4SO.js} +1 -1
  25. package/dist/elements/chunks/{attachments-BB56zoji.js → attachments-DpYMe5VQ.js} +1 -1
  26. package/dist/elements/chunks/{audio-visualizer-BxOx4THz.js → audio-visualizer-DFwzAcuL.js} +1 -1
  27. package/dist/elements/chunks/{badge-SxLSZ19R.js → badge-D6zodm6E.js} +1 -1
  28. package/dist/elements/chunks/{button-CmkUkz6c.js → button-C42vrGvO.js} +1 -1
  29. package/dist/elements/chunks/{card-renderer-Db4ffkWv.js → card-renderer-DMZsSuvU.js} +1 -1
  30. package/dist/elements/chunks/{chat-thread-B3Ks-cKr.js → chat-thread-CNNEhol-.js} +1 -1
  31. package/dist/elements/chunks/check-7Q3K-iXE.js +1 -0
  32. package/dist/elements/chunks/chevron-down-CpNYLn7E.js +1 -0
  33. package/dist/elements/chunks/chevron-right-DrH85Fha.js +1 -0
  34. package/dist/elements/chunks/{choice-card-Q9cYYDyM.js → choice-card-YoWjpsFV.js} +1 -1
  35. package/dist/elements/chunks/circle-C9KRjKYv.js +1 -0
  36. package/dist/elements/chunks/circle-check-Dmi7Y4zw.js +1 -0
  37. package/dist/elements/chunks/{circle-x-D0MEfxOM.js → circle-x-QIxXda6k.js} +1 -1
  38. package/dist/elements/chunks/{code-block-C8VzAraE.js → code-block-CqeugReY.js} +1 -1
  39. package/dist/elements/chunks/{collapsible-Cvj61obE.js → collapsible-sTv-_5lF.js} +1 -1
  40. package/dist/elements/chunks/{composer-D86ztyL0.js → composer-ldQyeVEB.js} +1 -1
  41. package/dist/elements/chunks/{confirm-card-BdyDAGLw.js → confirm-card-C0RNOwfn.js} +1 -1
  42. package/dist/elements/chunks/context-5qgypNHC.js +1 -0
  43. package/dist/elements/chunks/conversation-list-DLzOacXz.js +1 -0
  44. package/dist/elements/chunks/{create-tween-C_A_B638.js → create-tween-V4TXZ93X.js} +1 -1
  45. package/dist/elements/chunks/default-input-DeeRSDHc.js +1 -0
  46. package/dist/elements/chunks/{define-BzPnNsXg.js → define-lE-QvQFn.js} +1 -1
  47. package/dist/elements/chunks/{disclosure-DVKsoc1F.js → disclosure-DZSa9jog.js} +1 -1
  48. package/dist/elements/chunks/{download-BjA54IMJ.js → download-B2ZmnGqW.js} +1 -1
  49. package/dist/elements/chunks/{dropdown-BjVVHWXH.js → dropdown-R3B7cSRE.js} +1 -1
  50. package/dist/elements/chunks/{ellipsis-BNedmqfi.js → ellipsis-BmxceY4l.js} +1 -1
  51. package/dist/elements/chunks/{embed-DQsl4XEq.js → embed-BVrtTZPo.js} +1 -1
  52. package/dist/elements/chunks/{external-link-Di-dw2CU.js → external-link-BbdR7YO7.js} +1 -1
  53. package/dist/elements/chunks/{file-text-B3sAODCV.js → file-text-D7dijo1A.js} +1 -1
  54. package/dist/elements/chunks/{file-tree-Dgto-bMn.js → file-tree-0upJ5jeb.js} +1 -1
  55. package/dist/elements/chunks/{folder-D6rMadFV.js → folder-D8z58t7B.js} +1 -1
  56. package/dist/elements/chunks/{form-CILxvhQ7.js → form-p5BKJNw5.js} +1 -1
  57. package/dist/elements/chunks/{hover-card-BtiF4XiU.js → hover-card-CN9s2md5.js} +1 -1
  58. package/dist/elements/chunks/{icon-DKkmze3y.js → icon-CNiKP37E.js} +1 -1
  59. package/dist/elements/chunks/{info-CWRjZkuy.js → info-BSnuJiHI.js} +1 -1
  60. package/dist/elements/chunks/{input--QJBK6BA.js → input-BauDmzxX.js} +1 -1
  61. package/dist/elements/chunks/{kbd-C3zuKh8R.js → kbd-BlHJaP9b.js} +1 -1
  62. package/dist/elements/chunks/{link-BL5JA7Q0.js → link-DEi5iUWC.js} +1 -1
  63. package/dist/elements/chunks/{link-preview-CU37bbXh.js → link-preview-BaHyM6W4.js} +1 -1
  64. package/dist/elements/chunks/{loader-XmpyxyRH.js → loader-CmJG_Fkq.js} +1 -1
  65. package/dist/elements/chunks/{markdown-3Gv5UZ_o.js → markdown-D2jaMpCN.js} +1 -1
  66. package/dist/elements/chunks/{message-X5NZbq-W.js → message-C5UfXZ2Z.js} +1 -1
  67. package/dist/elements/chunks/{message-CnBA2OUt.js → message-CXWLmmLn.js} +1 -1
  68. package/dist/elements/chunks/message-square-hXylwsy0.js +1 -0
  69. package/dist/elements/chunks/{minimize-2-DwyrUTcM.js → minimize-2-C9ytZFMU.js} +1 -1
  70. package/dist/elements/chunks/model-switcher-CivGfD7-.js +1 -0
  71. package/dist/elements/chunks/{overlay-Cepd-KcS.js → overlay-DJL_sbOm.js} +1 -1
  72. package/dist/elements/chunks/{paperclip-CdwKjrlu.js → paperclip-CFaB8naM.js} +1 -1
  73. package/dist/elements/chunks/{progress-bar-DMVXBD3b.js → progress-bar-Br5SksfR.js} +1 -1
  74. package/dist/elements/chunks/{prompt-suggestion-Di_WZE_5.js → prompt-suggestion-CMIvtAhU.js} +1 -1
  75. package/dist/elements/chunks/{reasoning-D0jMch5u.js → reasoning-DpZcNzLq.js} +1 -1
  76. package/dist/elements/chunks/{resizable-1znbL1Gv.js → resizable-Q8i3T9a_.js} +1 -1
  77. package/dist/elements/chunks/{rotate-cw-DVhhgx8n.js → rotate-cw-B5IHCBFV.js} +1 -1
  78. package/dist/elements/chunks/{scroll-area-Bs4bFS1P.js → scroll-area-eLeydBje.js} +1 -1
  79. package/dist/elements/chunks/{scroll-button-Bet5NLc4.js → scroll-button-DWbz0yA1.js} +1 -1
  80. package/dist/elements/chunks/{separator-C5Gh8qGC.js → separator-Xic78AD_.js} +1 -1
  81. package/dist/elements/chunks/{settings-5vmEFg0K.js → settings-B8H51pAx.js} +1 -1
  82. package/dist/elements/chunks/{settings-group-BWLB38kz.js → settings-group-CsG9eYLe.js} +1 -1
  83. package/dist/elements/chunks/{share-BZ3G_DaB.js → share-CNTx0z__.js} +1 -1
  84. package/dist/elements/chunks/{skeleton-DafPJQTp.js → skeleton-CLlHTcMI.js} +1 -1
  85. package/dist/elements/chunks/{source-D_qj_6uJ.js → source-BoVcU5ok.js} +1 -1
  86. package/dist/elements/chunks/{star-Cuv7j2IE.js → star-CxQVTVhI.js} +1 -1
  87. package/dist/elements/chunks/{store-C1StPHfW.js → store-CLtS5CUG.js} +1 -1
  88. package/dist/elements/chunks/{tasks-card-EyoBZfpA.js → tasks-card-CzlkLmUt.js} +1 -1
  89. package/dist/elements/chunks/{text-shimmer-D9gRSh34.js → text-shimmer-D5NhpIQ1.js} +1 -1
  90. package/dist/elements/chunks/{textarea-DuhtHfcm.js → textarea-x5Uxsmtm.js} +1 -1
  91. package/dist/elements/chunks/{thumbs-up-B9hwv43w.js → thumbs-up-UobcJnx9.js} +1 -1
  92. package/dist/elements/chunks/{toast-store-Bm3DUEDe.js → toast-store-BOfs9eXq.js} +1 -1
  93. package/dist/elements/chunks/{tool-CDtW7L0z.js → tool-Be6Dh_dt.js} +1 -1
  94. package/dist/elements/chunks/{tooltip-eB226mGK.js → tooltip-RxMxyCLF.js} +1 -1
  95. package/dist/elements/chunks/{triangle-alert-C1SNzTqK.js → triangle-alert-C4yxH2XN.js} +1 -1
  96. package/dist/elements/chunks/{use-card-resolution-YZh2MvI5.js → use-card-resolution-jva9ehH6.js} +1 -1
  97. package/dist/elements/chunks/{variant-aurora-Cj1F7D0l.js → variant-aurora-Dx4VRcjS.js} +1 -1
  98. package/dist/elements/chunks/{variant-custom-T9gpt7QE.js → variant-custom-Y1ZTWgEa.js} +1 -1
  99. package/dist/elements/chunks/{variant-wave-CxU9z-TE.js → variant-wave-B9gml9Cg.js} +1 -1
  100. package/dist/elements/chunks/{video-CK4R7Rus.js → video-CrPHsytT.js} +1 -1
  101. package/dist/elements/chunks/x-_-OPHept.js +1 -0
  102. package/dist/elements/coachmark.js +1 -1
  103. package/dist/elements/code-block.js +1 -1
  104. package/dist/elements/command.js +1 -1
  105. package/dist/elements/compare.js +1 -1
  106. package/dist/elements/composer.js +1 -1
  107. package/dist/elements/confirm-card.js +1 -1
  108. package/dist/elements/context-meter.js +1 -1
  109. package/dist/elements/conversation-list.js +1 -1
  110. package/dist/elements/default-input.d.ts +27 -0
  111. package/dist/elements/dialog.js +1 -1
  112. package/dist/elements/editable-label.js +1 -1
  113. package/dist/elements/embed.js +1 -1
  114. package/dist/elements/empty.js +1 -1
  115. package/dist/elements/feedback-bar.js +1 -1
  116. package/dist/elements/file-tree.js +1 -1
  117. package/dist/elements/file-upload.js +1 -1
  118. package/dist/elements/form.js +1 -1
  119. package/dist/elements/hover-card.js +1 -1
  120. package/dist/elements/icon.js +1 -1
  121. package/dist/elements/image.js +1 -1
  122. package/dist/elements/input.js +1 -1
  123. package/dist/elements/kbd.js +1 -1
  124. package/dist/elements/link-preview.js +1 -1
  125. package/dist/elements/loader.js +1 -1
  126. package/dist/elements/markdown.js +1 -1
  127. package/dist/elements/menu.js +1 -1
  128. package/dist/elements/message-skills.js +1 -1
  129. package/dist/elements/message.js +1 -1
  130. package/dist/elements/model-switcher.js +1 -1
  131. package/dist/elements/nav.js +1 -1
  132. package/dist/elements/notice.js +1 -1
  133. package/dist/elements/pane-group.js +1 -1
  134. package/dist/elements/pane.js +1 -1
  135. package/dist/elements/popover.js +1 -1
  136. package/dist/elements/progress-bar.js +1 -1
  137. package/dist/elements/prompt-dock.js +1 -1
  138. package/dist/elements/prompt-input.js +1 -1
  139. package/dist/elements/prompt-suggestions.js +1 -1
  140. package/dist/elements/reasoning.js +1 -1
  141. package/dist/elements/remote.js +1 -1
  142. package/dist/elements/resizable.js +1 -1
  143. package/dist/elements/response-stream.js +2 -2
  144. package/dist/elements/screen.js +1 -1
  145. package/dist/elements/scroll-area.js +1 -1
  146. package/dist/elements/scroll-button.js +1 -1
  147. package/dist/elements/search.js +1 -1
  148. package/dist/elements/segmented.js +1 -1
  149. package/dist/elements/separator.js +1 -1
  150. package/dist/elements/setting-item.js +1 -1
  151. package/dist/elements/settings-group.js +1 -1
  152. package/dist/elements/skeleton.js +1 -1
  153. package/dist/elements/slot-text.d.ts +51 -0
  154. package/dist/elements/slots.d.ts +5 -0
  155. package/dist/elements/source.js +1 -1
  156. package/dist/elements/status.js +1 -1
  157. package/dist/elements/switch.js +1 -1
  158. package/dist/elements/tabs.js +1 -1
  159. package/dist/elements/tasks.js +1 -1
  160. package/dist/elements/text-shimmer.js +1 -1
  161. package/dist/elements/thinking-bar.js +1 -1
  162. package/dist/elements/thread.js +1 -1
  163. package/dist/elements/toast.js +1 -1
  164. package/dist/elements/tool.js +1 -1
  165. package/dist/elements/tooltip.js +1 -1
  166. package/dist/elements/voice-input.js +1 -1
  167. package/dist/elements/voice-output.js +1 -1
  168. package/dist/elements.d.ts +12 -6
  169. package/dist/index.js +1535 -1487
  170. package/dist/index.server.js +2330 -2296
  171. package/dist/kai.es.js +1 -1
  172. package/dist/llms/llms-full.txt +12 -3
  173. package/dist/mcp.es.js +809 -75
  174. package/dist/react/index.d.ts +13 -3
  175. package/dist/react.js +14 -14
  176. package/dist/register-impl-AxIlqjo7.js +145 -0
  177. package/dist/{solid-BCJUQqwS.js → solid-BGYSJyHG.js} +4901 -4691
  178. package/dist/{solid-BdENBcEq.js → solid-Dt2nEwfl.js} +4406 -4320
  179. package/dist/solid.js +1 -1
  180. package/dist/solid.server.js +1 -1
  181. package/dist/{variant-aurora-DOb_o4pC.js → variant-aurora-B44X69AG.js} +1 -1
  182. package/dist/{variant-aurora-BKHHiOgL.js → variant-aurora-BRhV8Flk.js} +2 -2
  183. package/dist/{variant-aurora-1wMcXRUS.js → variant-aurora-Ds5ou9BH.js} +2 -2
  184. package/dist/{variant-custom-DWSSgmd2.js → variant-custom-B20FhtYj.js} +2 -2
  185. package/dist/{variant-custom-CJGB0Kbw.js → variant-custom-BvA1Schl.js} +1 -1
  186. package/dist/{variant-custom-Co0CkXJF.js → variant-custom-D6Rtvneb.js} +2 -2
  187. package/dist/{variant-wave-CEODq-9q.js → variant-wave-BUQtkryq.js} +1 -1
  188. package/dist/{variant-wave-C3-45cL6.js → variant-wave-BVYnqy9L.js} +2 -2
  189. package/dist/{variant-wave-jdXBnCUZ.js → variant-wave-DXC6_LcH.js} +2 -2
  190. package/dist/wire/encode.d.ts +79 -6
  191. package/dist/wire/files.d.ts +116 -0
  192. package/dist/wire/index.d.ts +3 -1
  193. package/dist/wire/media-types.d.ts +106 -0
  194. package/dist/wire.js +596 -368
  195. package/frameworks/react/index.tsx +9 -5
  196. package/llms-full.txt +12 -3
  197. package/package.json +2 -1
  198. package/src/agent-tooling/archetypes.ts +43 -12
  199. package/src/agent-tooling/integrations/anthropic.ts +29 -5
  200. package/src/agent-tooling/integrations/cloudflare.ts +5 -0
  201. package/src/agent-tooling/integrations/langgraph.ts +13 -0
  202. package/src/agent-tooling/integrations/mastra.ts +46 -5
  203. package/src/agent-tooling/integrations/mock.ts +4 -0
  204. package/src/agent-tooling/integrations/ollama.ts +7 -0
  205. package/src/agent-tooling/integrations/openai.ts +2 -0
  206. package/src/agent-tooling/integrations/openrouter.ts +2 -0
  207. package/src/agent-tooling/integrations/pi.ts +26 -1
  208. package/src/agent-tooling/integrations/pydantic-ai.ts +37 -3
  209. package/src/agent-tooling/integrations/vercel-ai-sdk.ts +42 -6
  210. package/src/agent-tooling/mcp/tools/scaffold.ts +853 -32
  211. package/src/agent-tooling/registry.ts +57 -0
  212. package/src/agent-tooling/types.ts +132 -0
  213. package/src/components/chat-thread.tsx +10 -1
  214. package/src/components/context.tsx +18 -12
  215. package/src/components/conversation-item.tsx +6 -0
  216. package/src/components/model-switcher.tsx +38 -8
  217. package/src/components/toast.tsx +6 -1
  218. package/src/elements/button.tsx +26 -3
  219. package/src/elements/chat.tsx +28 -2
  220. package/src/elements/default-input.tsx +128 -5
  221. package/src/elements/element-meta.json +30 -4
  222. package/src/elements/element-types.d.ts +12 -6
  223. package/src/elements/menu.tsx +30 -3
  224. package/src/elements/slot-text.ts +73 -0
  225. package/src/elements/slots.ts +10 -0
  226. package/src/index.ts +9 -5
  227. package/src/wire/encode.ts +229 -14
  228. package/src/wire/files.ts +342 -0
  229. package/src/wire/index.ts +21 -0
  230. package/src/wire/media-types.ts +344 -0
  231. package/dist/elements/chunks/arrow-left-Bb46H7w9.js +0 -1
  232. package/dist/elements/chunks/check-C8desc_h.js +0 -1
  233. package/dist/elements/chunks/chevron-down-DKtc2HNB.js +0 -1
  234. package/dist/elements/chunks/chevron-right-Dw4GPhwL.js +0 -1
  235. package/dist/elements/chunks/circle-BeokBsvB.js +0 -1
  236. package/dist/elements/chunks/circle-check-wJSqt859.js +0 -1
  237. package/dist/elements/chunks/context-WHDl-xSx.js +0 -1
  238. package/dist/elements/chunks/conversation-list-CdR14_Y4.js +0 -1
  239. package/dist/elements/chunks/default-input-CSZqUM81.js +0 -1
  240. package/dist/elements/chunks/message-square-CnpAm_P0.js +0 -1
  241. package/dist/elements/chunks/model-switcher-CclaJEHt.js +0 -1
  242. package/dist/elements/chunks/x-D1qx0aLd.js +0 -1
  243. package/dist/register-impl-bzrZ44ft.js +0 -145
@@ -310,6 +310,20 @@ function realStreamBody(opts: {
310
310
  valueSource?: string;
311
311
  /** lines emitted right after the value is read and guarded */
312
312
  afterValue?: string[];
313
+ /**
314
+ * The attachments capability: an EXPRESSION producing this turn's staged
315
+ * attachments, which become `file` parts ahead of the message's text part.
316
+ *
317
+ * An expression rather than a flag because every framework holds the list
318
+ * somewhere different (a module-scope `let`, a signal, a rune), and it is
319
+ * evaluated exactly once here — in `afterValue` each renderer has already
320
+ * captured it into a local, because the same lines then CLEAR the staging list
321
+ * and a lazily-read accessor would come back empty.
322
+ *
323
+ * It sits in `parts` before the text so the thread renders the files above the
324
+ * message body, which is where a composer's own attachment chips sit.
325
+ */
326
+ filesExpr?: string;
313
327
  /**
314
328
  * The `mock` integration. Swaps ONLY the source of the stream — the canned
315
329
  * responder instead of `fetch('/api/chat')` — and leaves every other line
@@ -319,14 +333,23 @@ function realStreamBody(opts: {
319
333
  }): string {
320
334
  const {
321
335
  pad, read, commitSet, setterAdapter, setLoading, bodyPayload, strictRoles = false, toolLoop, thread,
322
- cards = false, valueSource = 'e.detail.value', afterValue = [], mock = false,
336
+ cards = false, valueSource = 'e.detail.value', afterValue = [], mock = false, filesExpr,
323
337
  } = opts;
324
338
  const asConst = strictRoles ? ' as const' : '';
325
339
  // Under strict TS an un-annotated array literal widens the part's `type` to
326
340
  // `string`, so the later commit fails TS2322. Plain-JS contexts (html) have no
327
341
  // type to annotate with.
328
342
  const historyType = strictRoles ? ': ChatMessage[]' : '';
329
- const userMessage = `{ id: crypto.randomUUID(), role: 'user'${asConst}, parts: [{ type: 'text', text: value }] }`;
343
+ // `type: 'file' as const` even though every call site here is contextually
344
+ // typed by a ChatMessage[] target: the spread's element type is computed from
345
+ // the callback's own return, BEFORE the surrounding array literal's contextual
346
+ // type reaches it, so without the assertion `type` widens to string and the
347
+ // whole assignment fails TS2322.
348
+ const fileParts = filesExpr
349
+ ? `...${filesExpr}.map((attachment) => ({ type: 'file'${asConst}, attachment })), `
350
+ : '';
351
+ const userMessage =
352
+ `{ id: crypto.randomUUID(), role: 'user'${asConst}, parts: [${fileParts}{ type: 'text', text: value }] }`;
330
353
 
331
354
  const open = toolLoop
332
355
  ? thread.open({ pad, userMessage, typed: strictRoles })
@@ -631,7 +654,7 @@ function assertCardToolFormat(integration: Integration): 'openai' | 'anthropic'
631
654
  *
632
655
  * KEYED ON A COMPONENTS LIST, not an archetype id, for the same reason
633
656
  * `renderSurface` is. A guard that took an archetype id could only ever check the
634
- * six presets, so the surfaces the feature multi-select makes reachable — the ones
657
+ * seven presets, so the surfaces the feature multi-select makes reachable — the ones
635
658
  * with no preset — would emit cards with nothing asserting they did. The archetype
636
659
  * axis was also the narrower one: `getArchetype` returns `undefined` for anything
637
660
  * not in the catalog, so the guard could not have been pointed at a new surface
@@ -691,13 +714,29 @@ function wireImportLines(opts: {
691
714
  cardTools?: boolean;
692
715
  /** the `mock` integration → import the shared responder, not a fetch encoder */
693
716
  mock?: boolean;
717
+ /**
718
+ * The attachments capability → `type AttachmentData`.
719
+ *
720
+ * The kit exports it from `@kitn.ai/ui/state` precisely because it is the type
721
+ * BOTH ends of this feature hold: `<kai-attachments>.items` and the
722
+ * `{ type: 'file', attachment }` MessagePart. Naming it is better than deriving
723
+ * a structural equivalent per framework, which is what the first cut did — a
724
+ * `NonNullable<AttachmentsProps['items']>[number]` is the same type spelled so
725
+ * that only React can spell it.
726
+ *
727
+ * Independent of `typed` on purpose: `solid` takes `ChatMessage` from
728
+ * `@kitn.ai/ui` alongside its components (so `typed` is false there) and still
729
+ * needs this one from the state entry.
730
+ */
731
+ attachments?: boolean;
694
732
  }): string[] {
695
- const { pad = '', typed, toolLoop = false, setMessagesType = false, cards = false, cardTools: emitsCardTools = false, mock = false } = opts;
733
+ const { pad = '', typed, toolLoop = false, setMessagesType = false, cards = false, cardTools: emitsCardTools = false, mock = false, attachments = false } = opts;
696
734
  const stateNames = [
697
735
  'createAssistantStream',
698
736
  // The mock's canned reply comes from the kit, not from a copy pasted into
699
737
  // this file. One implementation, shared with create-kai and the starters.
700
738
  ...(mock ? ['createMockResponder'] : []),
739
+ ...(attachments ? ['type AttachmentData'] : []),
701
740
  ...(typed ? ['type ChatMessage'] : []),
702
741
  ...(typed && setMessagesType ? ['type SetMessages'] : []),
703
742
  ].join(', ');
@@ -1014,6 +1053,210 @@ function isWorkspace(components: readonly string[]): boolean {
1014
1053
  return components.includes('kai-resizable') && components.includes('kai-artifact');
1015
1054
  }
1016
1055
 
1056
+ // ── attachments ───────────────────────────────────────────────────────────────
1057
+
1058
+ /**
1059
+ * The attachment staging pair. Structural in the same sense the workspace tags
1060
+ * are: neither is emitted as a bare sibling with nothing wired to it, because
1061
+ * neither does anything on its own.
1062
+ *
1063
+ * `<kai-file-upload>` is a dropzone that only EMITS (`kai-files-added`, carrying
1064
+ * `File[]`); it holds no list. `<kai-attachments>` only RENDERS what its `items`
1065
+ * property is set to, and an unset `items` draws the empty state. So the two
1066
+ * bare tags the generic companion fall-through used to emit were a dropzone
1067
+ * whose files went nowhere and a list that could never fill.
1068
+ */
1069
+ const ATTACHMENT_TAGS = new Set(['kai-file-upload', 'kai-attachments']);
1070
+
1071
+ /**
1072
+ * True when the surface stages attachments — BOTH tags, for the same reason
1073
+ * `isWorkspace` needs both.
1074
+ *
1075
+ * They are one capability group in `archetypes.ts`, so in practice a request
1076
+ * carries both or neither. Requiring both here is what keeps a hand-built
1077
+ * `components: ['kai-chat', 'kai-attachments']` from reaching the wiring below
1078
+ * and emitting a `kai-remove` listener for a list nothing can add to.
1079
+ */
1080
+ function hasAttachments(components: readonly string[]): boolean {
1081
+ return components.includes('kai-file-upload') && components.includes('kai-attachments');
1082
+ }
1083
+
1084
+ /**
1085
+ * What the scaffolder INTENDS to emit for one components list, so a guard can
1086
+ * check the seven renderers against the decision instead of restating it.
1087
+ *
1088
+ * The sibling of `cardEmitPlan`, and exported for the same reason:
1089
+ * `attachmentStagingCheck` in scripts/verify-scaffold-compiles.mjs would
1090
+ * otherwise re-derive "both attachment tags are present" itself, and a guard that
1091
+ * re-derives its own expectation stops being a check of anything the day the two
1092
+ * derivations disagree — it just agrees with itself.
1093
+ *
1094
+ * Unlike `cardEmitPlan` this takes no integration, and that is a real difference
1095
+ * rather than an omission: staging a file needs no model, no tools array and no
1096
+ * route, so `mock` emits exactly what `openai` does. What the wire then makes of
1097
+ * the files is decided by the encoders and by the media-type declaration behind
1098
+ * them, which is the same answer for every integration (see
1099
+ * `ATTACHMENT_WIRE_NOTE`).
1100
+ */
1101
+ export function attachmentEmitPlan(components: readonly string[]): { staging: boolean } {
1102
+ return { staging: hasAttachments(components) };
1103
+ }
1104
+
1105
+ /**
1106
+ * What the emitted attachment surface does, and — the part that matters — what
1107
+ * it does not.
1108
+ *
1109
+ * It stages AND it sends. `toOpenAIMessages` / `toAnthropicMessages` encode
1110
+ * `file` parts into the content blocks each API takes, so a staged file reaches
1111
+ * the model. That is worth stating because it was false for a long time and the
1112
+ * failure was silent: before #186 an attachment-only turn encoded to nothing at
1113
+ * all and was dropped from the request, so a scaffold could put a paperclip on
1114
+ * screen, accept a PDF, render it in the thread, and send the model a message
1115
+ * that never mentioned it.
1116
+ *
1117
+ * WHAT IT STILL DOES NOT DO. Both remaining limits are facts about the APIs, not
1118
+ * gaps in this scaffold, which is why the emitted note can state them plainly:
1119
+ * · Neither API has an arbitrary-file content block, so a media type outside
1120
+ * the kit's set — a `.zip` is the one people try — has no representation on
1121
+ * either wire and throws at encode time rather than going missing.
1122
+ * · A REMOTE text file is refused. Text has to ride as text CONTENT (no wire
1123
+ * has a URL form for it), so inlining one would mean fetching it, and
1124
+ * `wire/` does no I/O by design. Stage a `data:` URI instead.
1125
+ * A file the browser could not name is NOT on that list any more: it is settled
1126
+ * by decoding its bytes, so the `.rs` and `.toml` files a coding chat is full of
1127
+ * go through even though Chrome hands them over with no media type at all.
1128
+ *
1129
+ * ★ THE EMITTED NOTE MUST NOT NAME MEDIA TYPES, and that is the whole reason it
1130
+ * reads the way it does. It lands in a user's repo, where it outlives any
1131
+ * limitation it describes and where nothing can ever check it — a second copy of
1132
+ * the capability list, at the worst possible layer. It points at
1133
+ * `encodableMediaTypes()` instead, which is public precisely so that nobody has
1134
+ * to hardcode the set. A guard in `scaffold.test.ts` fails if a media type
1135
+ * creeps back into it.
1136
+ *
1137
+ * This very comment has been wrong before, in the way that matters most: #186
1138
+ * corrected the emitted string and left the explanation above it teaching the
1139
+ * reverse, so the scaffolder's own documentation said files never reach the
1140
+ * model while the code it emitted said they do. Anything written here that a
1141
+ * script cannot re-derive is a claim with a shelf life.
1142
+ */
1143
+ export const ATTACHMENT_WIRE_NOTE = [
1144
+ '// The staged files ride along on the message as `file` parts, so they RENDER',
1145
+ '// in the thread AND reach the model: toOpenAIMessages / toAnthropicMessages',
1146
+ '// encode each one into the content block its API takes.',
1147
+ '// Which is why toAttachment below stages a `data:` URI rather than a',
1148
+ '// URL.createObjectURL blob: an object URL previews fine and resolves ONLY',
1149
+ '// inside this tab, so the encoder rejects it rather than send an address the',
1150
+ '// provider cannot fetch.',
1151
+ '// What can be sent is not a list worth copying into a comment -- it moves, and',
1152
+ '// a copy cannot. `encodableMediaTypes()` from "@kitn.ai/ui/wire" IS the set,',
1153
+ '// and `resolveMediaPolicy().decide(mediaType)` answers for one file. Anything',
1154
+ '// outside it throws at encode time instead of going missing from the request.',
1155
+ '// A file the browser could not name (a .rs, a .toml) is settled by decoding',
1156
+ '// its bytes, never by its extension.',
1157
+ ];
1158
+
1159
+ /**
1160
+ * The `File` -> `AttachmentData` conversion, emitted as a real function.
1161
+ *
1162
+ * `<kai-file-upload>` hands over `File` objects and `<kai-attachments>` renders
1163
+ * `AttachmentData`, so something has to bridge them and it may as well be code
1164
+ * the consumer can edit. The object URL is what makes an image preview a real
1165
+ * thumbnail rather than a generic icon; it is deliberately not revoked here,
1166
+ * and the comment says why rather than leaving a silent leak.
1167
+ *
1168
+ * The parameter type is derived from the property it feeds
1169
+ * (`KaiAttachmentsElement['items']` / the wrapper's `items` prop) at each call
1170
+ * site rather than importing `AttachmentData`, for the reason the html target
1171
+ * already derives its message type from `KaiChatElement['messages']`: a type
1172
+ * read off the assignment target cannot drift out of step with it.
1173
+ */
1174
+ function fileToAttachmentLines(pad: string, typeName: string): string[] {
1175
+ return [
1176
+ `${pad}/** <kai-file-upload> hands over File objects; <kai-attachments> renders these. */`,
1177
+ `${pad}async function toAttachment(file: File): Promise<${typeName}> {`,
1178
+ `${pad} // A data: URI, NOT URL.createObjectURL. Both preview identically, but an`,
1179
+ `${pad} // object URL resolves only inside this tab, so it can never reach a model —`,
1180
+ `${pad} // the encoder rejects one rather than send an address the provider cannot`,
1181
+ `${pad} // fetch. Reading is async, which is why this function is.`,
1182
+ `${pad} const url = await new Promise<string>((resolve, reject) => {`,
1183
+ `${pad} const reader = new FileReader();`,
1184
+ `${pad} reader.onload = () => resolve(String(reader.result));`,
1185
+ `${pad} reader.onerror = () => reject(reader.error ?? new Error('Could not read ' + file.name));`,
1186
+ `${pad} reader.readAsDataURL(file);`,
1187
+ `${pad} });`,
1188
+ `${pad} return {`,
1189
+ `${pad} id: crypto.randomUUID(),`,
1190
+ `${pad} type: 'file',`,
1191
+ `${pad} filename: file.name,`,
1192
+ `${pad} // '' for an extensionless file, and an empty mediaType would render as a`,
1193
+ `${pad} // blank detail line rather than falling back to the generic icon.`,
1194
+ `${pad} mediaType: file.type || undefined,`,
1195
+ `${pad} url,`,
1196
+ `${pad} };`,
1197
+ `${pad}}`,
1198
+ ];
1199
+ }
1200
+
1201
+ /**
1202
+ * Why the submit handler merges TWO sources of staged files.
1203
+ *
1204
+ * `<kai-chat>` renders its own paperclip unconditionally — `ChatThread` always
1205
+ * passes `onAttachmentsChange` to the composer, and there is no prop on the
1206
+ * element that turns it off — so every scaffold ALREADY has an attach button,
1207
+ * and every scaffold before this one dropped whatever was attached through it on
1208
+ * the floor: `kai-submit` carries `{ value, attachments }` and every emitted
1209
+ * handler read only `.value`.
1210
+ *
1211
+ * So reading only the dropzone would leave that bug in place on the one surface
1212
+ * that is supposed to be about attachments. Both are folded in.
1213
+ */
1214
+ const ATTACHMENT_MERGE_NOTE = [
1215
+ "// <kai-chat> renders its OWN paperclip (there is no prop that hides it), and its",
1216
+ '// staged files arrive on this event as detail.attachments. The dropzone above is',
1217
+ '// the second source. Both are folded in — reading either one alone silently',
1218
+ '// drops files the user really attached.',
1219
+ ];
1220
+
1221
+ /**
1222
+ * The lines every framework emits at the TOP of a submit, before the message is
1223
+ * built: capture this turn's attachments into `files`, then empty the staging
1224
+ * list.
1225
+ *
1226
+ * They go through `afterValue` rather than into the message expression because
1227
+ * the order is load-bearing and easy to get backwards — `filesExpr` is spliced
1228
+ * into `parts` AFTER these run, so the capture has to be into a local. Reading
1229
+ * the staging list lazily from `parts` would read it post-clear and attach
1230
+ * nothing, which is precisely the silent failure this whole capability is being
1231
+ * added to stop.
1232
+ *
1233
+ * `fromSubmitEvent` is false for exactly one target: `solid` renders the SolidJS
1234
+ * `PromptInput` primitives directly rather than `<kai-chat>`, so there is no
1235
+ * `kai-submit` event and no second source to merge — its dropzone is the only
1236
+ * one. Merging a `detail` that does not exist there would be a runtime TypeError
1237
+ * on every submit.
1238
+ */
1239
+ function attachmentTurnLines(opts: {
1240
+ /** expression reading the dropzone's staged list */
1241
+ stagedExpr: string;
1242
+ /** statement(s) that empty the staging list (and re-render it) */
1243
+ clear: string[];
1244
+ /** the framework submits through `kai-submit`, so `e.detail.attachments` exists */
1245
+ fromSubmitEvent: boolean;
1246
+ }): string[] {
1247
+ const { stagedExpr, clear, fromSubmitEvent } = opts;
1248
+ return [
1249
+ ...(fromSubmitEvent ? ATTACHMENT_MERGE_NOTE : [
1250
+ '// This target renders PromptInput directly rather than <kai-chat>, so the',
1251
+ '// dropzone is the only source of staged files — there is no kai-submit detail.',
1252
+ ]),
1253
+ fromSubmitEvent
1254
+ ? `const files = [...${stagedExpr}, ...e.detail.attachments];`
1255
+ : `const files = [...${stagedExpr}];`,
1256
+ ...clear,
1257
+ ];
1258
+ }
1259
+
1017
1260
  /**
1018
1261
  * A sample assistant message showing embedded tool + reasoning.
1019
1262
  *
@@ -1136,15 +1379,37 @@ interface RenderCtx {
1136
1379
  *
1137
1380
  * SCAF-14: workspace structural types (kai-resizable, kai-artifact) are emitted
1138
1381
  * as a properly composed split layout — chat in one pane, artifact in the other.
1382
+ *
1383
+ * The attachment pair (kai-file-upload, kai-attachments) is filtered out of the
1384
+ * companion loop for the same reason the workspace pair is: it gets a composed
1385
+ * block of its own, above the chat, with ids the emitted module wires together.
1139
1386
  */
1140
1387
  function componentTags(components: readonly string[], chatFill: string): string {
1141
1388
  const companionTags = components.filter(
1142
- (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
1389
+ (t) =>
1390
+ t !== 'kai-chat' &&
1391
+ !MESSAGE_EMBEDDED_TAGS.has(t) &&
1392
+ !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
1393
+ !(hasAttachments(components) && ATTACHMENT_TAGS.has(t)),
1143
1394
  );
1144
1395
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
1145
1396
  const hasStandaloneCompanions = companionTags.length > 0;
1146
1397
 
1147
1398
  const lines: string[] = [];
1399
+ // The staging strip, ABOVE the thread: drop files in, see them as removable
1400
+ // chips, and they ride out on the next message. `flex: 0 0 auto` keeps it out
1401
+ // of the chat's `flex: 1` claim on the remaining height.
1402
+ if (hasAttachments(components)) {
1403
+ lines.push(
1404
+ ` <!-- Drop files here to stage them for the NEXT message. src/main.ts wires`,
1405
+ ` kai-files-added -> the staged list -> kai-attachments' items property. -->`,
1406
+ ` <div style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem;">`,
1407
+ ` <kai-file-upload id="upload" accept="image/*,application/pdf"></kai-file-upload>`,
1408
+ ` <!-- items is a JS PROPERTY (arrays can't be attributes); 'removable' fires kai-remove. -->`,
1409
+ ` <kai-attachments id="attachments" variant="inline" removable></kai-attachments>`,
1410
+ ` </div>`,
1411
+ );
1412
+ }
1148
1413
  // SCAF-14: workspace is a structural/layout surface — emit a runnable split.
1149
1414
  //
1150
1415
  // This used to `return` here, which dropped every standalone companion on the
@@ -1229,6 +1494,49 @@ function componentTags(components: readonly string[], chatFill: string): string
1229
1494
  function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
1230
1495
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
1231
1496
  const hasSources = components.includes('kai-sources');
1497
+ const attachments = hasAttachments(components);
1498
+
1499
+ // MODULE scope, like `model`/`runTool`: the staged list has to outlive `init()`
1500
+ // so the submit handler can read it, and `Staged` is used by the module-scope
1501
+ // `toAttachment` declaration below.
1502
+ const attachmentModuleLines = attachments
1503
+ ? [
1504
+ ...ATTACHMENT_WIRE_NOTE,
1505
+ `//`,
1506
+ `// AttachmentData is the kit's own type for a staged file — the same one`,
1507
+ `// <kai-attachments>.items holds and the same one a 'file' message part holds.`,
1508
+ `let staged: AttachmentData[] = [];`,
1509
+ ``,
1510
+ ...fileToAttachmentLines('', 'AttachmentData'),
1511
+ ``,
1512
+ ]
1513
+ : [];
1514
+
1515
+ const attachmentSetupLines = attachments
1516
+ ? [
1517
+ ` const uploadEl = document.getElementById('upload') as HTMLElement;`,
1518
+ ` const attachmentsEl = document.getElementById('attachments') as KaiAttachmentsElement;`,
1519
+ ` // Same upgrade rule as <kai-chat>: 'items' is an array, so it is a PROPERTY,`,
1520
+ ` // and a property set before the element upgrades is dropped on upgrade.`,
1521
+ ` await customElements.whenDefined('kai-attachments');`,
1522
+ ` // A NEW array per write — same contract as chat.messages.`,
1523
+ ` const showStaged = () => { attachmentsEl.items = [...staged]; };`,
1524
+ ``,
1525
+ ` uploadEl.addEventListener('kai-files-added', async (event: Event) => {`,
1526
+ ` const { files } = (event as CustomEvent<{ files: File[] }>).detail;`,
1527
+ ` // Read every file BEFORE appending: a per-file append would order the`,
1528
+ ` // list by whichever finished reading first.`,
1529
+ ` staged = [...staged, ...(await Promise.all(files.map(toAttachment)))];`,
1530
+ ` showStaged();`,
1531
+ ` });`,
1532
+ ` attachmentsEl.addEventListener('kai-remove', (event: Event) => {`,
1533
+ ` const { id } = (event as CustomEvent<{ id: string }>).detail;`,
1534
+ ` staged = staged.filter((a) => a.id !== id);`,
1535
+ ` showStaged();`,
1536
+ ` });`,
1537
+ ``,
1538
+ ]
1539
+ : [];
1232
1540
 
1233
1541
  // SCAF-9: the agentic archetype explains where tool + reasoning parts come
1234
1542
  // from. It no longer SEEDS a fabricated turn — see `SAMPLE_AGENTIC_MESSAGE`.
@@ -1272,8 +1580,15 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
1272
1580
 
1273
1581
  // KaiSourcesElement only when a kai-sources companion is really declared: a
1274
1582
  // stock vanilla-ts tsconfig sets noUnusedLocals, so an always-on import is a
1275
- // build error on every other archetype.
1276
- const elementTypes = hasSources ? 'KaiChatElement, KaiSourcesElement' : 'KaiChatElement';
1583
+ // build error on every other archetype. KaiAttachmentsElement follows the same
1584
+ // rule the `items =` assignment needs it. KaiFileUploadElement is deliberately
1585
+ // NOT imported: the dropzone is only ever listened to, and `as HTMLElement`
1586
+ // types addEventListener fully.
1587
+ const elementTypes = [
1588
+ 'KaiChatElement',
1589
+ ...(hasSources ? ['KaiSourcesElement'] : []),
1590
+ ...(attachments ? ['KaiAttachmentsElement'] : []),
1591
+ ].join(', ');
1277
1592
 
1278
1593
  /**
1279
1594
  * Same rule, applied to the kit's own `ChatMessage`.
@@ -1305,11 +1620,13 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
1305
1620
  cards: ctx.emitCards,
1306
1621
  cardTools: ctx.cardProvider !== null,
1307
1622
  mock: ctx.isMock,
1623
+ attachments,
1308
1624
  }),
1309
1625
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
1310
1626
  ``,
1311
1627
  ...(ctx.isMock ? [...mockResponderInit(), ``] : []),
1312
1628
  ...modelLines,
1629
+ ...attachmentModuleLines,
1313
1630
  ...cardsLines,
1314
1631
  ...toolsLines,
1315
1632
  ...runnerLines,
@@ -1326,14 +1643,21 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
1326
1643
  ...cardPropLines,
1327
1644
  ...seedLines,
1328
1645
  ...sourcesSetupLines,
1646
+ ...attachmentSetupLines,
1329
1647
  ];
1330
1648
 
1331
1649
  // `Event`, not `CustomEvent`: addEventListener with a custom event name hands
1332
1650
  // the listener a plain Event, so the narrowing happens in the body — the same
1333
1651
  // shape renderAngular emits, for the same reason.
1652
+ //
1653
+ // `attachments` is on the detail whether or not this surface stages files —
1654
+ // <kai-chat> always sends both — but it is only NAMED in the cast where the
1655
+ // handler reads it, so every other scaffold keeps the narrower type it uses.
1334
1656
  const listenerOpen = [
1335
1657
  ` chat.addEventListener('kai-submit', async (event: Event) => {`,
1336
- ` const e = event as CustomEvent<{ value: string }>;`,
1658
+ attachments
1659
+ ? ` const e = event as CustomEvent<{ value: string; attachments: AttachmentData[] }>;`
1660
+ : ` const e = event as CustomEvent<{ value: string }>;`,
1337
1661
  ];
1338
1662
  const footer = [
1339
1663
  ` });`,
@@ -1363,6 +1687,16 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
1363
1687
  'chat.messages ?? []',
1364
1688
  ),
1365
1689
  mock: ctx.isMock,
1690
+ ...(attachments
1691
+ ? {
1692
+ filesExpr: 'files',
1693
+ afterValue: attachmentTurnLines({
1694
+ stagedExpr: 'staged',
1695
+ clear: ['staged = [];', 'showStaged();'],
1696
+ fromSubmitEvent: true,
1697
+ }),
1698
+ }
1699
+ : {}),
1366
1700
  }),
1367
1701
  ...footer,
1368
1702
  ].join('\n');
@@ -1438,9 +1772,17 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1438
1772
  const wrapperNames = importTags.map(toPascalCase);
1439
1773
  const importList = wrapperNames.join(', ');
1440
1774
 
1441
- // SCAF-9: standalone companion tags (not kai-chat, not message-embedded, not workspace-structural).
1775
+ const attachments = hasAttachments(components);
1776
+
1777
+ // SCAF-9: standalone companion tags (not kai-chat, not message-embedded, not
1778
+ // workspace-structural, and not the attachment pair — that gets a composed
1779
+ // block of its own with state behind it).
1442
1780
  const standaloneCompanionTags = components.filter(
1443
- (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
1781
+ (t) =>
1782
+ t !== 'kai-chat' &&
1783
+ !MESSAGE_EMBEDDED_TAGS.has(t) &&
1784
+ !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
1785
+ !(attachments && ATTACHMENT_TAGS.has(t)),
1444
1786
  );
1445
1787
 
1446
1788
  // Build companion JSX: only standalone companions with real props.
@@ -1465,6 +1807,59 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1465
1807
  }
1466
1808
  const companions = companionJsxLines.join('\n');
1467
1809
 
1810
+ // The staging strip, ABOVE the chat. `<FileUpload>`/`<Attachments>` are the
1811
+ // generated wrappers, already in `importList` because both tags are in
1812
+ // `components` — no extra import bookkeeping, which is the point of keying the
1813
+ // import list off the components rather than off a per-capability table.
1814
+ const attachmentJsx = attachments
1815
+ ? [
1816
+ ` {/* Drop files here to stage them for the NEXT message. */}`,
1817
+ ` <div style={{ flex: '0 0 auto', display: 'flex', flexDirection: 'column', gap: '0.5rem', padding: '0.75rem' }}>`,
1818
+ ` <FileUpload accept="image/*,application/pdf" onFilesAdded={onFilesAdded} />`,
1819
+ ` {/* items is an ARRAY, so the wrapper sets it as a DOM property, never an attribute. */}`,
1820
+ ` <Attachments items={staged} variant="inline" removable onRemove={onRemoveAttachment} />`,
1821
+ ` </div>`,
1822
+ ].join('\n')
1823
+ : '';
1824
+
1825
+ // MODULE scope for `toAttachment` (it closes over nothing); component scope for
1826
+ // the state and the two handlers.
1827
+ const attachmentModuleInit = attachments
1828
+ ? [
1829
+ ...ATTACHMENT_WIRE_NOTE,
1830
+ `//`,
1831
+ `// AttachmentData is the kit's own type for a staged file — the same one`,
1832
+ `// <Attachments>.items holds and the same one a 'file' message part holds.`,
1833
+ ...fileToAttachmentLines('', 'AttachmentData'),
1834
+ ]
1835
+ : [];
1836
+ const attachmentStateInit = attachments
1837
+ ? [
1838
+ ` const [staged, setStaged] = useState<AttachmentData[]>([]);`,
1839
+ ` const onFilesAdded = async (e: CustomEvent<{ files: File[] }>) => {`,
1840
+ ` // Read every file BEFORE appending: a per-file append would order the`,
1841
+ ` // list by whichever finished reading first.`,
1842
+ ` const added = await Promise.all(e.detail.files.map(toAttachment));`,
1843
+ ` setStaged((prev) => [...prev, ...added]);`,
1844
+ ` };`,
1845
+ ` const onRemoveAttachment = (e: CustomEvent<{ id: string }>) =>`,
1846
+ ` setStaged((prev) => prev.filter((a) => a.id !== e.detail.id));`,
1847
+ ].join('\n')
1848
+ : '';
1849
+ const attachmentSubmitOpts = attachments
1850
+ ? {
1851
+ filesExpr: 'files',
1852
+ afterValue: attachmentTurnLines({
1853
+ stagedExpr: 'staged',
1854
+ clear: ['setStaged([]);'],
1855
+ fromSubmitEvent: true,
1856
+ }),
1857
+ }
1858
+ : {};
1859
+ const submitParamType = attachments
1860
+ ? 'CustomEvent<{ value: string; attachments: AttachmentData[] }>'
1861
+ : 'CustomEvent<{ value: string }>';
1862
+
1468
1863
  const mockInit = isMock ? mockResponderInit() : [];
1469
1864
 
1470
1865
  // SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE for the three ways
@@ -1528,6 +1923,7 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1528
1923
  cards: ctx.emitCards,
1529
1924
  thread: REACT_THREAD,
1530
1925
  mock: isMock,
1926
+ ...attachmentSubmitOpts,
1531
1927
  });
1532
1928
 
1533
1929
  // SCAF-2: Next.js App Router requires 'use client' for components that use hooks/interactivity.
@@ -1564,6 +1960,7 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1564
1960
  cards: ctx.emitCards,
1565
1961
  cardTools: ctx.cardProvider !== null,
1566
1962
  mock: isMock,
1963
+ attachments,
1567
1964
  }),
1568
1965
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
1569
1966
  `// <kai-*> are client-only custom elements (the server has no customElements`,
@@ -1576,22 +1973,25 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1576
1973
  ...(p.altNote ?? []).map((l) => `// ${l}`),
1577
1974
  ...mockInit,
1578
1975
  ``,
1976
+ ...attachmentModuleInit,
1579
1977
  ...cardsInit,
1580
1978
  `export default function App() {`,
1581
1979
  sampleMessagesInit,
1582
1980
  ` const [loading, setLoading] = useState(false);`,
1583
1981
  ` const suggestions = ${jsArray(suggestions)};`,
1982
+ ...(attachmentStateInit ? [attachmentStateInit] : []),
1584
1983
  ...(sampleSourcesInit ? [sampleSourcesInit] : []),
1585
1984
  ...(modelInit ? [modelInit] : []),
1586
1985
  ...(toolsInit ? [toolsInit] : []),
1587
1986
  ...(toolRunner ? [toolRunner] : []),
1588
1987
  ``,
1589
- ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
1988
+ ` async function onSubmit(e: ${submitParamType}) {`,
1590
1989
  onSubmitBody,
1591
1990
  ` }`,
1592
1991
  ``,
1593
1992
  ` return (`,
1594
1993
  ` <div style={{ ${jsxStyle(p.style)} }}>`,
1994
+ ...(attachmentJsx ? [attachmentJsx] : []),
1595
1995
  ...(workspace
1596
1996
  ? [
1597
1997
  ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
@@ -1659,6 +2059,7 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1659
2059
  cards: ctx.emitCards,
1660
2060
  cardTools: ctx.cardProvider !== null,
1661
2061
  mock: isMock,
2062
+ attachments,
1662
2063
  }),
1663
2064
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
1664
2065
  ``,
@@ -1667,22 +2068,25 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1667
2068
  ...(p.altNote ?? []).map((l) => `// ${l}`),
1668
2069
  ...mockInit,
1669
2070
  ``,
2071
+ ...attachmentModuleInit,
1670
2072
  ...cardsInit,
1671
2073
  `export default function App() {`,
1672
2074
  sampleMessagesInit,
1673
2075
  ` const [loading, setLoading] = useState(false);`,
1674
2076
  ` const suggestions = ${jsArray(suggestions)};`,
2077
+ ...(attachmentStateInit ? [attachmentStateInit] : []),
1675
2078
  ...(sampleSourcesInit ? [sampleSourcesInit] : []),
1676
2079
  ...(modelInit ? [modelInit] : []),
1677
2080
  ...(toolsInit ? [toolsInit] : []),
1678
2081
  ...(toolRunner ? [toolRunner] : []),
1679
2082
  ``,
1680
- ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
2083
+ ` async function onSubmit(e: ${submitParamType}) {`,
1681
2084
  onSubmitBody,
1682
2085
  ` }`,
1683
2086
  ``,
1684
2087
  ` return (`,
1685
2088
  ` <div style={{ ${jsxStyle(p.style)} }}>`,
2089
+ ...(attachmentJsx ? [attachmentJsx] : []),
1686
2090
  ...(workspace
1687
2091
  ? [
1688
2092
  ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
@@ -1736,8 +2140,13 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
1736
2140
  // SCAF-9: exclude message-embedded tags from companion rendering.
1737
2141
  // SCAF-14: also exclude workspace structural tags (handled by the workspace block below).
1738
2142
  const workspace = isWorkspace(components);
2143
+ const attachments = hasAttachments(components);
1739
2144
  const standaloneCompanionTags = components.filter(
1740
- (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
2145
+ (t) =>
2146
+ t !== 'kai-chat' &&
2147
+ !MESSAGE_EMBEDDED_TAGS.has(t) &&
2148
+ !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
2149
+ !(attachments && ATTACHMENT_TAGS.has(t)),
1741
2150
  );
1742
2151
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
1743
2152
 
@@ -1758,6 +2167,55 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
1758
2167
  }
1759
2168
  const companions = companionLines.join('\n');
1760
2169
 
2170
+ // `:items.prop` for the same reason `:messages.prop` carries the thread: an
2171
+ // array set as an ATTRIBUTE stringifies, and `<kai-attachments>` would render
2172
+ // the empty state. The listeners are plain `@kai-*` — Vue maps an unknown-tag
2173
+ // event name straight to addEventListener, which is how `@kai-submit` already
2174
+ // works here.
2175
+ const attachmentTemplate = attachments
2176
+ ? [
2177
+ ` <!-- Drop files here to stage them for the NEXT message. -->`,
2178
+ ` <div style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem;">`,
2179
+ ` <kai-file-upload accept="image/*,application/pdf" @kai-files-added="onFilesAdded" />`,
2180
+ ` <kai-attachments :items.prop="staged" variant="inline" removable @kai-remove="onRemoveAttachment" />`,
2181
+ ` </div>`,
2182
+ ].join('\n')
2183
+ : '';
2184
+
2185
+ const attachmentScript = attachments
2186
+ ? [
2187
+ ...ATTACHMENT_WIRE_NOTE,
2188
+ `//`,
2189
+ `// AttachmentData is the kit's own type for a staged file, and it is what`,
2190
+ `// <kai-attachments>.items and a 'file' MessagePart both hold.`,
2191
+ `const staged = ref<AttachmentData[]>([]);`,
2192
+ ``,
2193
+ ...fileToAttachmentLines('', 'AttachmentData'),
2194
+ ``,
2195
+ `async function onFilesAdded(event: Event) {`,
2196
+ ` const { files } = (event as CustomEvent<{ files: File[] }>).detail;`,
2197
+ ` // Read every file BEFORE appending: a per-file append would order the`,
2198
+ ` // list by whichever finished reading first.`,
2199
+ ` const added = await Promise.all(files.map(toAttachment));`,
2200
+ ` staged.value = [...staged.value, ...added];`,
2201
+ `}`,
2202
+ `function onRemoveAttachment(event: Event) {`,
2203
+ ` const { id } = (event as CustomEvent<{ id: string }>).detail;`,
2204
+ ` staged.value = staged.value.filter((a) => a.id !== id);`,
2205
+ `}`,
2206
+ ]
2207
+ : [];
2208
+ const attachmentSubmitOpts = attachments
2209
+ ? {
2210
+ filesExpr: 'files',
2211
+ afterValue: attachmentTurnLines({
2212
+ stagedExpr: 'staged.value',
2213
+ clear: ['staged.value = [];'],
2214
+ fromSubmitEvent: true,
2215
+ }),
2216
+ }
2217
+ : {};
2218
+
1761
2219
  const onSubmitBody = realStreamBody({
1762
2220
  pad: ' ',
1763
2221
  read: 'messages.value',
@@ -1770,6 +2228,7 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
1770
2228
  cards: ctx.emitCards,
1771
2229
  thread: liveThreadBinding('messages.value', '(fn) => { messages.value = fn(messages.value); }'),
1772
2230
  mock: isMock,
2231
+ ...attachmentSubmitOpts,
1773
2232
  });
1774
2233
 
1775
2234
  // SCAF-10: ChatMessage declaration for strict-TS Vue consumers.
@@ -1879,6 +2338,7 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
1879
2338
  cards: ctx.emitCards,
1880
2339
  cardTools: ctx.cardProvider !== null,
1881
2340
  mock: isMock,
2341
+ attachments,
1882
2342
  }),
1883
2343
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
1884
2344
  vueImports,
@@ -1893,6 +2353,7 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
1893
2353
  ...toolsLines,
1894
2354
  ...runnerLines,
1895
2355
  ...sourcesSeed,
2356
+ ...attachmentScript,
1896
2357
  ``,
1897
2358
  `// SCAF-15: kai-* register via an async dynamic import (SSR-safety). The .prop`,
1898
2359
  `// bindings can apply before the element upgrades, which drops them — re-apply once`,
@@ -1904,13 +2365,16 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
1904
2365
  ` if (el) Object.assign(el, { messages: messages.value, loading: loading.value, suggestions${cardPropAssign.length ? `, ${cardPropAssign.join(', ')}` : ''} });`,
1905
2366
  `});`,
1906
2367
  ``,
1907
- `async function onSubmit(e: CustomEvent<{ value: string }>) {`,
2368
+ `async function onSubmit(e: ${attachments
2369
+ ? 'CustomEvent<{ value: string; attachments: AttachmentData[] }>'
2370
+ : 'CustomEvent<{ value: string }>'}) {`,
1908
2371
  onSubmitBody,
1909
2372
  `}`,
1910
2373
  `</script>`,
1911
2374
  ``,
1912
2375
  `<template>`,
1913
2376
  ` <div style="${p.style}">`,
2377
+ ...(attachmentTemplate ? [attachmentTemplate] : []),
1914
2378
  ...workspaceTemplate,
1915
2379
  ` </div>`,
1916
2380
  `</template>`,
@@ -1926,8 +2390,13 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
1926
2390
  // SCAF-9: exclude message-embedded tags from companion rendering.
1927
2391
  // SCAF-14: also exclude workspace structural tags (handled by the workspace block below).
1928
2392
  const workspace = isWorkspace(components);
2393
+ const attachments = hasAttachments(components);
1929
2394
  const standaloneCompanionTags = components.filter(
1930
- (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
2395
+ (t) =>
2396
+ t !== 'kai-chat' &&
2397
+ !MESSAGE_EMBEDDED_TAGS.has(t) &&
2398
+ !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
2399
+ !(attachments && ATTACHMENT_TAGS.has(t)),
1931
2400
  );
1932
2401
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
1933
2402
  const hasSourcesCompanion = standaloneCompanionTags.includes('kai-sources');
@@ -1949,6 +2418,17 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
1949
2418
  }
1950
2419
  const companionLines = companionLinesList.join('\n');
1951
2420
 
2421
+ const attachmentSubmitOpts = attachments
2422
+ ? {
2423
+ filesExpr: 'files',
2424
+ afterValue: attachmentTurnLines({
2425
+ stagedExpr: 'staged',
2426
+ clear: ['staged = [];'],
2427
+ fromSubmitEvent: true,
2428
+ }),
2429
+ }
2430
+ : {};
2431
+
1952
2432
  const onSubmitBody = realStreamBody({
1953
2433
  pad: ' ',
1954
2434
  read: 'messages',
@@ -1961,6 +2441,7 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
1961
2441
  cards: ctx.emitCards,
1962
2442
  thread: liveThreadBinding('messages', '(fn) => { messages = fn(messages); }'),
1963
2443
  mock: isMock,
2444
+ ...attachmentSubmitOpts,
1964
2445
  });
1965
2446
 
1966
2447
  // SCAF-10: ChatMessage declaration for strict-TS Svelte consumers.
@@ -2021,6 +2502,43 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
2021
2502
  ]
2022
2503
  : [];
2023
2504
 
2505
+ // Svelte 5: the element ref is $state (bind:this writes to it), and the staged
2506
+ // list is $state.raw for the same reason `messages` is — a NEW array per write is
2507
+ // the kit's contract, and deep state would proxy every object on its way into a
2508
+ // Solid-backed custom element. `items` is an ARRAY, so it is applied through the
2509
+ // same upgrade-gated $effect chatEl.messages goes through, never as an attribute.
2510
+ const attachmentScript = attachments
2511
+ ? [
2512
+ ...ATTACHMENT_WIRE_NOTE.map((l) => ` ${l}`),
2513
+ ` //`,
2514
+ ` // AttachmentData is the kit's own type for a staged file, and it is what`,
2515
+ ` // <kai-attachments>.items and a 'file' MessagePart both hold.`,
2516
+ ` let attachmentsEl = $state<KaiAttachmentsElement | undefined>(undefined);`,
2517
+ ` let staged = $state.raw<AttachmentData[]>([]);`,
2518
+ ` $effect(() => { if (attachmentsEl && defined) { attachmentsEl.items = staged; } });`,
2519
+ ``,
2520
+ ...fileToAttachmentLines(' ', 'AttachmentData'),
2521
+ ` async function onFilesAdded(e: CustomEvent<{ files: File[] }>) {`,
2522
+ ` // Read every file BEFORE appending: a per-file append would order the`,
2523
+ ` // list by whichever finished reading first.`,
2524
+ ` const added = await Promise.all(e.detail.files.map(toAttachment));`,
2525
+ ` staged = [...staged, ...added];`,
2526
+ ` }`,
2527
+ ` function onRemoveAttachment(e: CustomEvent<{ id: string }>) {`,
2528
+ ` staged = staged.filter((a) => a.id !== e.detail.id);`,
2529
+ ` }`,
2530
+ ]
2531
+ : [];
2532
+ const attachmentMarkup = attachments
2533
+ ? [
2534
+ ` <!-- Drop files here to stage them for the NEXT message. -->`,
2535
+ ` <div style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem;">`,
2536
+ ` <kai-file-upload accept="image/*,application/pdf" onkai-files-added={onFilesAdded}></kai-file-upload>`,
2537
+ ` <kai-attachments bind:this={attachmentsEl} variant="inline" removable onkai-remove={onRemoveAttachment}></kai-attachments>`,
2538
+ ` </div>`,
2539
+ ]
2540
+ : [];
2541
+
2024
2542
  // SCAF-14: workspace template block — resizable split with chat + artifact panes.
2025
2543
  const workspaceMarkup = workspace
2026
2544
  ? [
@@ -2055,7 +2573,11 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
2055
2573
  // KaiSourcesElement is only imported when a kai-sources companion is actually
2056
2574
  // declared below: an always-on import would be unused (and fail noUnusedLocals)
2057
2575
  // on every archetype without kai-sources.
2058
- ` import type { ${hasSourcesCompanion ? 'KaiChatElement, KaiSourcesElement' : 'KaiChatElement'} } from '@kitn.ai/ui/elements';`,
2576
+ ` import type { ${[
2577
+ 'KaiChatElement',
2578
+ ...(hasSourcesCompanion ? ['KaiSourcesElement'] : []),
2579
+ ...(attachments ? ['KaiAttachmentsElement'] : []),
2580
+ ].join(', ')} } from '@kitn.ai/ui/elements';`,
2059
2581
  ...wireImportLines({
2060
2582
  pad: ' ',
2061
2583
  typed: true,
@@ -2063,6 +2585,7 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
2063
2585
  cards: ctx.emitCards,
2064
2586
  cardTools: ctx.cardProvider !== null,
2065
2587
  mock: isMock,
2588
+ attachments,
2066
2589
  }),
2067
2590
  ` import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
2068
2591
  ` import { onMount } from 'svelte';`,
@@ -2088,13 +2611,17 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
2088
2611
  ` if (chatEl && defined) { chatEl.messages = messages; chatEl.loading = loading; chatEl.suggestions = suggestions;${cardPropEffect} }`,
2089
2612
  ` });`,
2090
2613
  ...sourcesReactive,
2614
+ ...attachmentScript,
2091
2615
  ``,
2092
- ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
2616
+ ` async function onSubmit(e: ${attachments
2617
+ ? 'CustomEvent<{ value: string; attachments: AttachmentData[] }>'
2618
+ : 'CustomEvent<{ value: string }>'}) {`,
2093
2619
  onSubmitBody,
2094
2620
  ` }`,
2095
2621
  `</script>`,
2096
2622
  ``,
2097
2623
  `<div style="${p.style}">`,
2624
+ ...attachmentMarkup,
2098
2625
  ...workspaceMarkup,
2099
2626
  `</div>`,
2100
2627
  ]
@@ -2151,8 +2678,14 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2151
2678
  const wrapperNames = importTags.map(toPascalCase);
2152
2679
  const importList = wrapperNames.join(', ');
2153
2680
 
2681
+ const attachments = hasAttachments(components);
2682
+
2154
2683
  const standaloneCompanionTags = components.filter(
2155
- (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
2684
+ (t) =>
2685
+ t !== 'kai-chat' &&
2686
+ !MESSAGE_EMBEDDED_TAGS.has(t) &&
2687
+ !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
2688
+ !(attachments && ATTACHMENT_TAGS.has(t)),
2156
2689
  );
2157
2690
 
2158
2691
  const companionJsxLines: string[] = [];
@@ -2175,6 +2708,54 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2175
2708
  }
2176
2709
  const companions = companionJsxLines.join('\n');
2177
2710
 
2711
+ // Same three pieces as `renderJsx` — this target IS React. See there for why
2712
+ // the type comes from @kitn.ai/ui/state and why the strip sits above the chat.
2713
+ const attachmentJsx = attachments
2714
+ ? [
2715
+ ` {/* Drop files here to stage them for the NEXT message. */}`,
2716
+ ` <div style={{ flex: '0 0 auto', display: 'flex', flexDirection: 'column', gap: '0.5rem', padding: '0.75rem' }}>`,
2717
+ ` <FileUpload accept="image/*,application/pdf" onFilesAdded={onFilesAdded} />`,
2718
+ ` {/* items is an ARRAY, so the wrapper sets it as a DOM property, never an attribute. */}`,
2719
+ ` <Attachments items={staged} variant="inline" removable onRemove={onRemoveAttachment} />`,
2720
+ ` </div>`,
2721
+ ].join('\n')
2722
+ : '';
2723
+ const attachmentModuleInit = attachments
2724
+ ? [
2725
+ ...ATTACHMENT_WIRE_NOTE,
2726
+ `//`,
2727
+ `// AttachmentData is the kit's own type for a staged file — the same one`,
2728
+ `// <Attachments>.items holds and the same one a 'file' message part holds.`,
2729
+ ...fileToAttachmentLines('', 'AttachmentData'),
2730
+ ]
2731
+ : [];
2732
+ const attachmentStateInit = attachments
2733
+ ? [
2734
+ ` const [staged, setStaged] = useState<AttachmentData[]>([]);`,
2735
+ ` const onFilesAdded = async (e: CustomEvent<{ files: File[] }>) => {`,
2736
+ ` // Read every file BEFORE appending: a per-file append would order the`,
2737
+ ` // list by whichever finished reading first.`,
2738
+ ` const added = await Promise.all(e.detail.files.map(toAttachment));`,
2739
+ ` setStaged((prev) => [...prev, ...added]);`,
2740
+ ` };`,
2741
+ ` const onRemoveAttachment = (e: CustomEvent<{ id: string }>) =>`,
2742
+ ` setStaged((prev) => prev.filter((a) => a.id !== e.detail.id));`,
2743
+ ].join('\n')
2744
+ : '';
2745
+ const attachmentSubmitOpts = attachments
2746
+ ? {
2747
+ filesExpr: 'files',
2748
+ afterValue: attachmentTurnLines({
2749
+ stagedExpr: 'staged',
2750
+ clear: ['setStaged([]);'],
2751
+ fromSubmitEvent: true,
2752
+ }),
2753
+ }
2754
+ : {};
2755
+ const submitParamType = attachments
2756
+ ? 'CustomEvent<{ value: string; attachments: AttachmentData[] }>'
2757
+ : 'CustomEvent<{ value: string }>';
2758
+
2178
2759
  const mockInit = isMock ? mockResponderInit() : [];
2179
2760
 
2180
2761
  // SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE.
@@ -2227,6 +2808,7 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2227
2808
  cards: ctx.emitCards,
2228
2809
  thread: REACT_THREAD,
2229
2810
  mock: isMock,
2811
+ ...attachmentSubmitOpts,
2230
2812
  });
2231
2813
 
2232
2814
  // File path guidance for TanStack Start (file-based routing)
@@ -2260,6 +2842,7 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2260
2842
  cards: ctx.emitCards,
2261
2843
  cardTools: ctx.cardProvider !== null,
2262
2844
  mock: isMock,
2845
+ attachments,
2263
2846
  }),
2264
2847
  `import '@kitn.ai/ui/theme.tokens.css' // compiled token defaults`,
2265
2848
  ``,
@@ -2267,6 +2850,7 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2267
2850
  ...(p.altNote ?? []).map((l) => `// ${l}`),
2268
2851
  ...mockInit,
2269
2852
  ``,
2853
+ ...attachmentModuleInit,
2270
2854
  ...cardsInit,
2271
2855
  `// ssr: false keeps the Solid-based web component client-only.`,
2272
2856
  `// Server HTML for /chat omits <kai-chat> → no hydration mismatch.`,
@@ -2279,17 +2863,19 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2279
2863
  sampleMessagesInit,
2280
2864
  ` const [loading, setLoading] = useState(false);`,
2281
2865
  ` const suggestions = ${jsArray(suggestions)};`,
2866
+ ...(attachmentStateInit ? [attachmentStateInit] : []),
2282
2867
  ...(sampleSourcesInit ? [sampleSourcesInit] : []),
2283
2868
  ...(modelInit ? [modelInit] : []),
2284
2869
  ...(toolsInit ? [toolsInit] : []),
2285
2870
  ...(toolRunner ? [toolRunner] : []),
2286
2871
  ``,
2287
- ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
2872
+ ` async function onSubmit(e: ${submitParamType}) {`,
2288
2873
  onSubmitBody,
2289
2874
  ` }`,
2290
2875
  ``,
2291
2876
  ` return (`,
2292
2877
  ` <main style={{ ${jsxStyle(p.style)} }}>`,
2878
+ ...(attachmentJsx ? [attachmentJsx] : []),
2293
2879
  ...(workspace
2294
2880
  ? [
2295
2881
  ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
@@ -2366,8 +2952,13 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
2366
2952
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
2367
2953
 
2368
2954
  const workspace = isWorkspace(components);
2955
+ const attachments = hasAttachments(components);
2369
2956
  const standaloneCompanionTags = components.filter(
2370
- (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
2957
+ (t) =>
2958
+ t !== 'kai-chat' &&
2959
+ !MESSAGE_EMBEDDED_TAGS.has(t) &&
2960
+ !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
2961
+ !(attachments && ATTACHMENT_TAGS.has(t)),
2371
2962
  );
2372
2963
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
2373
2964
  const hasSourcesCompanion = standaloneCompanionTags.includes('kai-sources');
@@ -2390,6 +2981,61 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
2390
2981
  }
2391
2982
  }
2392
2983
 
2984
+ // `[items]` is a PROPERTY binding for the same reason `[messages]` is: an array
2985
+ // written as an attribute stringifies. Angular's own signal read goes in the
2986
+ // template, so no afterNextRender re-apply is needed for it — the binding
2987
+ // re-runs on every signal change, including the first one after upgrade.
2988
+ const attachmentTemplate = attachments
2989
+ ? [
2990
+ ` <!-- Drop files here to stage them for the NEXT message. -->`,
2991
+ ` <div style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem;">`,
2992
+ ` <kai-file-upload accept="image/*,application/pdf" (kai-files-added)="onFilesAdded($event)"></kai-file-upload>`,
2993
+ ` <kai-attachments [items]="staged()" variant="inline" removable (kai-remove)="onRemoveAttachment($event)"></kai-attachments>`,
2994
+ ` </div>`,
2995
+ ]
2996
+ : [];
2997
+ // Module scope for `toAttachment` (a class holds no bare function declaration),
2998
+ // class scope for the signal and the two handlers the template calls.
2999
+ const attachmentModuleInit = attachments
3000
+ ? [
3001
+ ...ATTACHMENT_WIRE_NOTE,
3002
+ `//`,
3003
+ `// AttachmentData is the kit's own type for a staged file, and it is what`,
3004
+ `// <kai-attachments>.items and a 'file' MessagePart both hold.`,
3005
+ ...fileToAttachmentLines('', 'AttachmentData'),
3006
+ ``,
3007
+ ]
3008
+ : [];
3009
+ const attachmentFields = attachments
3010
+ ? [
3011
+ ` readonly staged = signal<AttachmentData[]>([]);`,
3012
+ ``,
3013
+ ` // Same Event-not-CustomEvent rule as onSubmit below: strictTemplates types`,
3014
+ ` // $event on an unknown custom-element event as a plain Event.`,
3015
+ ` async onFilesAdded(event: Event) {`,
3016
+ ` const { files } = (event as CustomEvent<{ files: File[] }>).detail;`,
3017
+ ` // Read every file BEFORE appending: a per-file append would order the`,
3018
+ ` // list by whichever finished reading first.`,
3019
+ ` const added = await Promise.all(files.map(toAttachment));`,
3020
+ ` this.staged.set([...this.staged(), ...added]);`,
3021
+ ` }`,
3022
+ ` onRemoveAttachment(event: Event) {`,
3023
+ ` const { id } = (event as CustomEvent<{ id: string }>).detail;`,
3024
+ ` this.staged.set(this.staged().filter((a) => a.id !== id));`,
3025
+ ` }`,
3026
+ ]
3027
+ : [];
3028
+ const attachmentSubmitOpts = attachments
3029
+ ? {
3030
+ filesExpr: 'files',
3031
+ afterValue: attachmentTurnLines({
3032
+ stagedExpr: 'this.staged()',
3033
+ clear: ['this.staged.set([]);'],
3034
+ fromSubmitEvent: true,
3035
+ }),
3036
+ }
3037
+ : {};
3038
+
2393
3039
  // Angular signals: `this.messages()` reads, `this.messages.set(next)` writes a
2394
3040
  // BRAND-NEW array, which is what re-renders <kai-chat>.
2395
3041
  const read = 'this.messages()';
@@ -2408,6 +3054,7 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
2408
3054
  cards: ctx.emitCards,
2409
3055
  thread: accessorThreadBinding(read, commit, setter),
2410
3056
  mock: isMock,
3057
+ ...attachmentSubmitOpts,
2411
3058
  });
2412
3059
 
2413
3060
  // Module scope, exactly like vue: a class can hold neither a bare `const` nor a
@@ -2495,6 +3142,9 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
2495
3142
  // declared: an always-on import is unused on every other archetype, and a stock
2496
3143
  // Angular tsconfig turns on the checks that make that a build error.
2497
3144
  const elementTypes = hasSourcesCompanion ? 'KaiChatElement, KaiSourcesElement' : 'KaiChatElement';
3145
+ // No KaiAttachmentsElement here: unlike svelte/html this target never holds an
3146
+ // element reference for the list — `[items]` is a template binding, so nothing
3147
+ // in the class is typed by it.
2498
3148
 
2499
3149
  return [
2500
3150
  `// Angular standalone component — save as: src/app/chat.component.ts`,
@@ -2516,6 +3166,7 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
2516
3166
  cards: ctx.emitCards,
2517
3167
  cardTools: ctx.cardProvider !== null,
2518
3168
  mock: isMock,
3169
+ attachments,
2519
3170
  }),
2520
3171
  ``,
2521
3172
  `// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
@@ -2523,6 +3174,7 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
2523
3174
  ...(isMock ? mockResponderInit() : []),
2524
3175
  ``,
2525
3176
  ...modelInit,
3177
+ ...attachmentModuleInit,
2526
3178
  ...cardsInit,
2527
3179
  ...toolsLines,
2528
3180
  ...runnerLines,
@@ -2534,6 +3186,7 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
2534
3186
  ` schemas: [CUSTOM_ELEMENTS_SCHEMA],`,
2535
3187
  ` template: \``,
2536
3188
  ` <div style="${p.style}">`,
3189
+ ...attachmentTemplate,
2537
3190
  ...templateBody,
2538
3191
  ` </div>`,
2539
3192
  ` \`,`,
@@ -2566,11 +3219,13 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
2566
3219
  ` });`,
2567
3220
  ` }`,
2568
3221
  ``,
3222
+ ...attachmentFields,
3223
+ ``,
2569
3224
  ` // \`Event\`, not \`CustomEvent\`: under strictTemplates Angular types \`$event\` on`,
2570
3225
  ` // an unknown custom-element event as a plain Event, so the narrowing happens`,
2571
3226
  ` // here rather than in the signature.`,
2572
3227
  ` async onSubmit(event: Event) {`,
2573
- ` const e = event as CustomEvent<{ value: string }>;`,
3228
+ ` const e = event as CustomEvent<{ value: string${attachments ? '; attachments: AttachmentData[]' : ''} }>;`,
2574
3229
  onSubmitBody,
2575
3230
  ` }`,
2576
3231
  `}`,
@@ -2625,8 +3280,13 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
2625
3280
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
2626
3281
 
2627
3282
  const workspace = isWorkspace(components);
3283
+ const attachments = hasAttachments(components);
2628
3284
  const standaloneCompanionTags = components.filter(
2629
- (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
3285
+ (t) =>
3286
+ t !== 'kai-chat' &&
3287
+ !MESSAGE_EMBEDDED_TAGS.has(t) &&
3288
+ !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
3289
+ !(attachments && ATTACHMENT_TAGS.has(t)),
2630
3290
  );
2631
3291
  const hasSources = standaloneCompanionTags.includes('kai-sources');
2632
3292
  const hasVoice = standaloneCompanionTags.includes('kai-voice-input');
@@ -2669,6 +3329,12 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
2669
3329
  'Tool',
2670
3330
  ...(workspace ? ['Artifact', 'ResizableHandle', 'ResizablePanel', 'ResizablePanelGroup'] : []),
2671
3331
  ...(hasVoice ? ['VoiceInput'] : []),
3332
+ // `Attachments`/`Attachment`/`AttachmentPreview`/`AttachmentInfo` are already
3333
+ // unconditional above — `renderPart` draws `file` PARTS with them whatever the
3334
+ // surface asked for. These three are the STAGING half, which only this
3335
+ // capability composes: the dropzone, its trigger, and the per-chip remove
3336
+ // button `renderPart` has no use for (a sent file is not removable).
3337
+ ...(attachments ? ['AttachmentRemove', 'FileUpload', 'FileUploadTrigger'] : []),
2672
3338
  ].sort();
2673
3339
 
2674
3340
  // Solid signals: `messages()` reads, `setMessages(next)` writes a NEW array.
@@ -2691,7 +3357,19 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
2691
3357
  cards: ctx.emitCards,
2692
3358
  thread: accessorThreadBinding(read, commit, setter),
2693
3359
  valueSource: 'input()',
2694
- afterValue: [`setInput('');`],
3360
+ // The staged files are captured BEFORE either list is cleared. `setInput('')`
3361
+ // stays last so the order reads the way it executes.
3362
+ afterValue: [
3363
+ ...(attachments
3364
+ ? attachmentTurnLines({
3365
+ stagedExpr: 'staged()',
3366
+ clear: ['setStaged([]);'],
3367
+ fromSubmitEvent: false,
3368
+ })
3369
+ : []),
3370
+ `setInput('');`,
3371
+ ],
3372
+ ...(attachments ? { filesExpr: 'files' } : {}),
2695
3373
  mock: isMock,
2696
3374
  });
2697
3375
 
@@ -2718,6 +3396,17 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
2718
3396
  ]
2719
3397
  : [];
2720
3398
 
3399
+ // The staging state. `toAttachment` is declared INSIDE App() here, unlike the
3400
+ // kai-* targets: this file's module scope is where `renderPart` and the card
3401
+ // registry live, and nothing outside the component needs it.
3402
+ const attachmentInit = attachments
3403
+ ? [
3404
+ ...ATTACHMENT_WIRE_NOTE.map((l) => ` ${l}`),
3405
+ ` const [staged, setStaged] = createSignal<AttachmentData[]>([]);`,
3406
+ ...fileToAttachmentLines(' ', 'AttachmentData'),
3407
+ ]
3408
+ : [];
3409
+
2721
3410
  const sourcesInit = hasSources
2722
3411
  ? [
2723
3412
  ` // Replace sampleSources with your real source data.`,
@@ -2802,6 +3491,37 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
2802
3491
  ` </For>`,
2803
3492
  ` </div>`,
2804
3493
  ` </Show>`,
3494
+ ...(attachments
3495
+ ? [
3496
+ ` {/* Drop files here to stage them for the NEXT message. */}`,
3497
+ ` <div class="flex flex-col gap-2 pb-3">`,
3498
+ ` {/* Read every file BEFORE appending: a per-file append would order`,
3499
+ ` the list by whichever finished reading first. */}`,
3500
+ ` <FileUpload`,
3501
+ ` accept="image/*,application/pdf"`,
3502
+ ` onFilesAdded={async (files) => setStaged([...staged(), ...(await Promise.all(files.map(toAttachment)))])}`,
3503
+ ` >`,
3504
+ ` <FileUploadTrigger class="border-border text-muted-foreground w-full rounded-xl border border-dashed px-4 py-3 text-center text-sm">`,
3505
+ ` Click or drop files to attach`,
3506
+ ` </FileUploadTrigger>`,
3507
+ ` </FileUpload>`,
3508
+ ` {/* The staged chips. onRemove is what makes <AttachmentRemove> draw its button. */}`,
3509
+ ` <Show when={staged().length > 0}>`,
3510
+ ` <Attachments variant="inline">`,
3511
+ ` <For each={staged()}>`,
3512
+ ` {(a) => (`,
3513
+ ` <Attachment data={a} onRemove={() => setStaged(staged().filter((x) => x.id !== a.id))}>`,
3514
+ ` <AttachmentPreview />`,
3515
+ ` <AttachmentInfo />`,
3516
+ ` <AttachmentRemove />`,
3517
+ ` </Attachment>`,
3518
+ ` )}`,
3519
+ ` </For>`,
3520
+ ` </Attachments>`,
3521
+ ` </Show>`,
3522
+ ` </div>`,
3523
+ ]
3524
+ : []),
2805
3525
  ` <PromptInput value={input()} onValueChange={setInput} onSubmit={onSubmit} isLoading={loading()}>`,
2806
3526
  ` <div class="flex flex-col">`,
2807
3527
  ` <PromptInputTextarea placeholder="Send a message…" class="min-h-[44px] pt-3 pl-4" />`,
@@ -2872,11 +3592,27 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
2872
3592
  `// every kit utility class as unused, and the whole UI renders unstyled.`,
2873
3593
  `// (theme.css here, not theme.tokens.css: this app compiles Tailwind itself.)`,
2874
3594
  `import { For, Index, Match, Show, Switch, createMemo, createSignal } from 'solid-js';`,
3595
+ // '@kitn.ai/ui/solid', NOT the root '@kitn.ai/ui', and the difference is
3596
+ // invisible to every compiler on this repo's critical path: src/solid.ts is
3597
+ // `export * from './index'`, so ./solid is a strict SUPERSET of the root and
3598
+ // both specifiers typecheck identically. verify:scaffold's solid project
3599
+ // compiled clean the whole time this was wrong.
3600
+ //
3601
+ // The root is the shared layer EVERY consumer resolves — React, Vue, Svelte
3602
+ // and vanilla included — so it deliberately carries only the chat components,
3603
+ // types and helpers those frameworks can use. The rest of the Solid catalog
3604
+ // lives on ./solid, built as its own bundle so a React app never pays for it.
3605
+ // A Solid app pointed at the root therefore gets the smaller surface and finds
3606
+ // out the hard way, one missing export at a time. The published guide says so
3607
+ // outright ("Import Solid components from @kitn.ai/ui/solid, not from the root
3608
+ // @kitn.ai/ui"), and this generator contradicted it for every Solid scaffold.
2875
3609
  `import {`,
2876
3610
  ...componentImports.map((n) => ` ${n},`),
2877
- `} from '@kitn.ai/ui';`,
3611
+ `} from '@kitn.ai/ui/solid';`,
2878
3612
  `// The kit's own types, from the same entry the components come from.`,
2879
- `import type { ChatMessage, MessagePart, MessageSource } from '@kitn.ai/ui';`,
3613
+ `import type { ${attachments
3614
+ ? 'AttachmentData, ChatMessage, MessagePart, MessageSource'
3615
+ : 'ChatMessage, MessagePart, MessageSource'} } from '@kitn.ai/ui/solid';`,
2880
3616
  ...wireImportLines({
2881
3617
  typed: false,
2882
3618
  toolLoop: emitToolLoop,
@@ -3080,6 +3816,7 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
3080
3816
  ` // value, no row moves, and the content updates through accessors instead.`,
3081
3817
  ` const messageKeys = createMemo(() => messages().map((m) => m.id));`,
3082
3818
  ` const suggestions = ${jsArray(suggestions)};`,
3819
+ ...attachmentInit,
3083
3820
  ...sourcesInit,
3084
3821
  ...modelInit,
3085
3822
  ...toolsLines,
@@ -3159,7 +3896,7 @@ export interface SurfaceRequest {
3159
3896
  * The `kai-*` components this surface composes — THE AXIS, not an archetype id.
3160
3897
  *
3161
3898
  * A list rather than a preset name is the whole point of this function. The six
3162
- * archetypes are six points in this space and the CLI's feature multi-select
3899
+ * archetypes are seven points in this space and the CLI's feature multi-select
3163
3900
  * reaches the rest of it, so a renderer keyed on a preset id could only ever
3164
3901
  * emit what someone had already thought to name. `kai-chat` is expected to be
3165
3902
  * present; everything else is a capability.
@@ -3718,6 +4455,85 @@ const CHAT_REQUEST_BODY_DECL = [
3718
4455
  `}`,
3719
4456
  ];
3720
4457
 
4458
+ /**
4459
+ * Attachments, on the way IN.
4460
+ *
4461
+ * A user turn's `content` is a plain string until it carries a file, at which
4462
+ * point `toOpenAIMessages` emits the ARRAY form. Every route that re-maps
4463
+ * messages into some other SDK's shape has to handle both, and the three that do
4464
+ * (anthropic, mastra, vercel-ai-sdk) were each written when only the string form
4465
+ * existed — so each one would have quietly dropped the attachment while still
4466
+ * compiling, which is the same defect the encoder was just fixed for.
4467
+ *
4468
+ * These two helpers are the shared half of that: flattening the wire shape is
4469
+ * identical everywhere, while the target shape is not, so each route maps
4470
+ * `WirePart[]` into its own SDK itself rather than inheriting a lowest common
4471
+ * denominator.
4472
+ *
4473
+ * Only injected into routes that actually call them — the eight pass-through
4474
+ * integrations forward `messages` untouched and need none of this, and an unused
4475
+ * declaration is a hard error under the gate's `--noUnusedLocals`.
4476
+ */
4477
+ const CONTENT_PARTS_DECL = [
4478
+ `/** Where an attachment's bytes are: inline base64, or an address the PROVIDER`,
4479
+ ` * fetches. Never both. */`,
4480
+ `type WireFileSource = { type: 'data'; data: string } | { type: 'url'; url: string };`,
4481
+ ``,
4482
+ `/** One piece of a turn, with the string and array content forms flattened into`,
4483
+ ` * a single shape. */`,
4484
+ `type WirePart =`,
4485
+ ` | { kind: 'text'; text: string }`,
4486
+ ` | { kind: 'file'; mediaType: string; filename?: string; source: WireFileSource };`,
4487
+ ``,
4488
+ `const DATA_URI = /^data:([^;,]+);base64,([\\s\\S]*)$/;`,
4489
+ ``,
4490
+ `/**`,
4491
+ ` * Flatten a wire message's content into parts.`,
4492
+ ` *`,
4493
+ ` * An image sent by URL has no media type here — \`image_url\` carries only the`,
4494
+ ` * address — so it reports the top-level segment \`'image'\`, which is all a URL`,
4495
+ ` * source needs. Only images can reach that branch: the kit refuses to encode a`,
4496
+ ` * remote PDF rather than guess at one.`,
4497
+ ` */`,
4498
+ `function wireParts(content: OpenAIWireMessage['content']): WirePart[] {`,
4499
+ ` if (content == null) return [];`,
4500
+ ` if (typeof content === 'string') return content === '' ? [] : [{ kind: 'text', text: content }];`,
4501
+ ` return content.map((part): WirePart => {`,
4502
+ ` if (part.type === 'text') return { kind: 'text', text: part.text };`,
4503
+ ` if (part.type === 'image_url') {`,
4504
+ ` const asData = DATA_URI.exec(part.image_url.url);`,
4505
+ ` return asData`,
4506
+ ` ? { kind: 'file', mediaType: asData[1], source: { type: 'data', data: asData[2] } }`,
4507
+ ` : { kind: 'file', mediaType: 'image', source: { type: 'url', url: part.image_url.url } };`,
4508
+ ` }`,
4509
+ ` const asData = DATA_URI.exec(part.file.file_data);`,
4510
+ ` if (!asData) {`,
4511
+ ` // LOUD on purpose. \`file_data\` is a data URI on this wire; anything else`,
4512
+ ` // cannot be turned into bytes without fetching it, and forwarding a turn`,
4513
+ ` // with the attachment quietly missing is the bug this whole path exists`,
4514
+ ` // to prevent.`,
4515
+ ` throw new Error(`,
4516
+ ` 'Unsupported file content part: file_data must be a data: URI of the form data:<media type>;base64,<data>.',`,
4517
+ ` );`,
4518
+ ` }`,
4519
+ ` return {`,
4520
+ ` kind: 'file',`,
4521
+ ` mediaType: asData[1],`,
4522
+ ` filename: part.file.filename,`,
4523
+ ` source: { type: 'data', data: asData[2] },`,
4524
+ ` };`,
4525
+ ` });`,
4526
+ `}`,
4527
+ ``,
4528
+ `/** Just the text of a turn. System, assistant and tool messages are text-only`,
4529
+ ` * on this wire, so this collapses the array form for them. */`,
4530
+ `function wireText(content: OpenAIWireMessage['content']): string {`,
4531
+ ` return wireParts(content)`,
4532
+ ` .map((p) => (p.kind === 'text' ? p.text : ''))`,
4533
+ ` .join('');`,
4534
+ `}`,
4535
+ ];
4536
+
3721
4537
  /**
3722
4538
  * Slot the body type in just above `chatHandler`.
3723
4539
  *
@@ -3728,11 +4544,16 @@ const CHAT_REQUEST_BODY_DECL = [
3728
4544
  * written it.
3729
4545
  */
3730
4546
  function withChatRequestBody(fragment: string): string {
4547
+ // The content helpers ride along only where the route calls them; see
4548
+ // CONTENT_PARTS_DECL for why an unconditional injection would not compile.
4549
+ const decl = /\bwire(?:Parts|Text)\s*\(/.test(fragment)
4550
+ ? [...CHAT_REQUEST_BODY_DECL, ``, ...CONTENT_PARTS_DECL]
4551
+ : CHAT_REQUEST_BODY_DECL;
3731
4552
  const lines = fragment.split('\n');
3732
4553
  let at = lines.findIndex((l) => /^(?:export\s+)?async function chatHandler\b/.test(l));
3733
- if (at < 0) return [...CHAT_REQUEST_BODY_DECL, ``, ...lines].join('\n');
4554
+ if (at < 0) return [...decl, ``, ...lines].join('\n');
3734
4555
  while (at > 0 && /^\s*(?:\/\/|\/\*|\*)/.test(lines[at - 1])) at -= 1;
3735
- return [...lines.slice(0, at), ...CHAT_REQUEST_BODY_DECL, ``, ...lines.slice(at)].join('\n');
4556
+ return [...lines.slice(0, at), ...decl, ``, ...lines.slice(at)].join('\n');
3736
4557
  }
3737
4558
 
3738
4559
  /** Wrap an integration's portable handler in the target framework's declaration. */
@@ -4342,7 +5163,7 @@ function rejectUseCase(id: string): string {
4342
5163
  ``,
4343
5164
  `Valid useCases (presets): ${valid}.`,
4344
5165
  ``,
4345
- // The presets are six points, not the space. A harness that only ever learns
5166
+ // The presets are seven points, not the space. A harness that only ever learns
4346
5167
  // the six ids will ask for the nearest one instead of the surface it wants,
4347
5168
  // so the rejection that teaches the id list is the right place to say so.
4348
5169
  `These are PRESETS over the real axis, which is \`components\`. To compose a surface no`,
@@ -4368,14 +5189,14 @@ export const scaffold: Tool = {
4368
5189
  // catalogs to discover valid ids.
4369
5190
  //
4370
5191
  // `useCase` is OPTIONAL because `components` can carry the surface instead —
4371
- // the archetypes are six points in the components space, not the space. A
5192
+ // the archetypes are seven points in the components space, not the space. A
4372
5193
  // request must still name one of the two, and the handler says so when it
4373
5194
  // names neither.
4374
5195
  useCase: z
4375
5196
  .string()
4376
5197
  .optional()
4377
5198
  .describe(
4378
- 'Archetype PRESET id, e.g. "drop-in-chat", "support-widget", "knowledge-base", "agentic", "workspace", "voice". ' +
5199
+ 'Archetype PRESET id, e.g. "drop-in-chat", "support-widget", "knowledge-base", "agentic", "workspace", "voice", "attachments". ' +
4379
5200
  'Shorthand for the preset\'s `components`. Omit it and pass `components` to compose a surface no preset names.',
4380
5201
  ),
4381
5202
  components: z
@@ -4383,7 +5204,7 @@ export const scaffold: Tool = {
4383
5204
  .optional()
4384
5205
  .describe(
4385
5206
  'The kai-* components this surface composes, e.g. ["kai-chat", "kai-tool", "kai-reasoning", "kai-artifact", "kai-resizable"]. ' +
4386
- 'The real axis: any combination is renderable, not just the six presets. Include "kai-chat". Wins over `useCase` when both are given.',
5207
+ 'The real axis: any combination is renderable, not just the seven presets. Include "kai-chat". Wins over `useCase` when both are given.',
4387
5208
  ),
4388
5209
  integration: z
4389
5210
  .string()