@kitn.ai/ui 0.21.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +890 -78
  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 +4 -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 +913 -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 +234 -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
@@ -8,6 +8,12 @@ import {
8
8
  listArchetypes,
9
9
  listIntegrations,
10
10
  } from '../../registry';
11
+ // The kit's media-type declaration, read rather than restated. This is the one
12
+ // import in agent-tooling that reaches outside itself, and the module it reaches
13
+ // for is the reason: `wire/media-types.ts` is pure (no I/O, no DOM, no solid-js),
14
+ // so the Node MCP pass typechecks and bundles it unchanged. See
15
+ // `ATTACHMENT_ACCEPT`.
16
+ import { encodableMediaTypes } from '../../../wire/media-types';
11
17
 
12
18
  /**
13
19
  * scaffold — the keystone tool. Composes a working chat surface from four axes:
@@ -310,6 +316,20 @@ function realStreamBody(opts: {
310
316
  valueSource?: string;
311
317
  /** lines emitted right after the value is read and guarded */
312
318
  afterValue?: string[];
319
+ /**
320
+ * The attachments capability: an EXPRESSION producing this turn's staged
321
+ * attachments, which become `file` parts ahead of the message's text part.
322
+ *
323
+ * An expression rather than a flag because every framework holds the list
324
+ * somewhere different (a module-scope `let`, a signal, a rune), and it is
325
+ * evaluated exactly once here — in `afterValue` each renderer has already
326
+ * captured it into a local, because the same lines then CLEAR the staging list
327
+ * and a lazily-read accessor would come back empty.
328
+ *
329
+ * It sits in `parts` before the text so the thread renders the files above the
330
+ * message body, which is where a composer's own attachment chips sit.
331
+ */
332
+ filesExpr?: string;
313
333
  /**
314
334
  * The `mock` integration. Swaps ONLY the source of the stream — the canned
315
335
  * responder instead of `fetch('/api/chat')` — and leaves every other line
@@ -319,14 +339,23 @@ function realStreamBody(opts: {
319
339
  }): string {
320
340
  const {
321
341
  pad, read, commitSet, setterAdapter, setLoading, bodyPayload, strictRoles = false, toolLoop, thread,
322
- cards = false, valueSource = 'e.detail.value', afterValue = [], mock = false,
342
+ cards = false, valueSource = 'e.detail.value', afterValue = [], mock = false, filesExpr,
323
343
  } = opts;
324
344
  const asConst = strictRoles ? ' as const' : '';
325
345
  // Under strict TS an un-annotated array literal widens the part's `type` to
326
346
  // `string`, so the later commit fails TS2322. Plain-JS contexts (html) have no
327
347
  // type to annotate with.
328
348
  const historyType = strictRoles ? ': ChatMessage[]' : '';
329
- const userMessage = `{ id: crypto.randomUUID(), role: 'user'${asConst}, parts: [{ type: 'text', text: value }] }`;
349
+ // `type: 'file' as const` even though every call site here is contextually
350
+ // typed by a ChatMessage[] target: the spread's element type is computed from
351
+ // the callback's own return, BEFORE the surrounding array literal's contextual
352
+ // type reaches it, so without the assertion `type` widens to string and the
353
+ // whole assignment fails TS2322.
354
+ const fileParts = filesExpr
355
+ ? `...${filesExpr}.map((attachment) => ({ type: 'file'${asConst}, attachment })), `
356
+ : '';
357
+ const userMessage =
358
+ `{ id: crypto.randomUUID(), role: 'user'${asConst}, parts: [${fileParts}{ type: 'text', text: value }] }`;
330
359
 
331
360
  const open = toolLoop
332
361
  ? thread.open({ pad, userMessage, typed: strictRoles })
@@ -631,7 +660,7 @@ function assertCardToolFormat(integration: Integration): 'openai' | 'anthropic'
631
660
  *
632
661
  * KEYED ON A COMPONENTS LIST, not an archetype id, for the same reason
633
662
  * `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
663
+ * seven presets, so the surfaces the feature multi-select makes reachable — the ones
635
664
  * with no preset — would emit cards with nothing asserting they did. The archetype
636
665
  * axis was also the narrower one: `getArchetype` returns `undefined` for anything
637
666
  * not in the catalog, so the guard could not have been pointed at a new surface
@@ -691,13 +720,29 @@ function wireImportLines(opts: {
691
720
  cardTools?: boolean;
692
721
  /** the `mock` integration → import the shared responder, not a fetch encoder */
693
722
  mock?: boolean;
723
+ /**
724
+ * The attachments capability → `type AttachmentData`.
725
+ *
726
+ * The kit exports it from `@kitn.ai/ui/state` precisely because it is the type
727
+ * BOTH ends of this feature hold: `<kai-attachments>.items` and the
728
+ * `{ type: 'file', attachment }` MessagePart. Naming it is better than deriving
729
+ * a structural equivalent per framework, which is what the first cut did — a
730
+ * `NonNullable<AttachmentsProps['items']>[number]` is the same type spelled so
731
+ * that only React can spell it.
732
+ *
733
+ * Independent of `typed` on purpose: `solid` takes `ChatMessage` from
734
+ * `@kitn.ai/ui` alongside its components (so `typed` is false there) and still
735
+ * needs this one from the state entry.
736
+ */
737
+ attachments?: boolean;
694
738
  }): string[] {
695
- const { pad = '', typed, toolLoop = false, setMessagesType = false, cards = false, cardTools: emitsCardTools = false, mock = false } = opts;
739
+ const { pad = '', typed, toolLoop = false, setMessagesType = false, cards = false, cardTools: emitsCardTools = false, mock = false, attachments = false } = opts;
696
740
  const stateNames = [
697
741
  'createAssistantStream',
698
742
  // The mock's canned reply comes from the kit, not from a copy pasted into
699
743
  // this file. One implementation, shared with create-kai and the starters.
700
744
  ...(mock ? ['createMockResponder'] : []),
745
+ ...(attachments ? ['type AttachmentData'] : []),
701
746
  ...(typed ? ['type ChatMessage'] : []),
702
747
  ...(typed && setMessagesType ? ['type SetMessages'] : []),
703
748
  ].join(', ');
@@ -1014,6 +1059,264 @@ function isWorkspace(components: readonly string[]): boolean {
1014
1059
  return components.includes('kai-resizable') && components.includes('kai-artifact');
1015
1060
  }
1016
1061
 
1062
+ // ── attachments ───────────────────────────────────────────────────────────────
1063
+
1064
+ /**
1065
+ * The attachment staging pair. Structural in the same sense the workspace tags
1066
+ * are: neither is emitted as a bare sibling with nothing wired to it, because
1067
+ * neither does anything on its own.
1068
+ *
1069
+ * `<kai-file-upload>` is a dropzone that only EMITS (`kai-files-added`, carrying
1070
+ * `File[]`); it holds no list. `<kai-attachments>` only RENDERS what its `items`
1071
+ * property is set to, and an unset `items` draws the empty state. So the two
1072
+ * bare tags the generic companion fall-through used to emit were a dropzone
1073
+ * whose files went nowhere and a list that could never fill.
1074
+ */
1075
+ const ATTACHMENT_TAGS = new Set(['kai-file-upload', 'kai-attachments']);
1076
+
1077
+ /**
1078
+ * True when the surface stages attachments — BOTH tags, for the same reason
1079
+ * `isWorkspace` needs both.
1080
+ *
1081
+ * They are one capability group in `archetypes.ts`, so in practice a request
1082
+ * carries both or neither. Requiring both here is what keeps a hand-built
1083
+ * `components: ['kai-chat', 'kai-attachments']` from reaching the wiring below
1084
+ * and emitting a `kai-remove` listener for a list nothing can add to.
1085
+ */
1086
+ function hasAttachments(components: readonly string[]): boolean {
1087
+ return components.includes('kai-file-upload') && components.includes('kai-attachments');
1088
+ }
1089
+
1090
+ /**
1091
+ * ★ THE EMITTED `accept`, DERIVED. The seven renderers below interpolate this;
1092
+ * none of them writes a media type.
1093
+ *
1094
+ * It used to be the literal `image/*,application/pdf` in all seven, which was
1095
+ * right when only images and PDFs could be encoded and silently stopped being
1096
+ * right when #190 made `text/*`, `application/json`, `application/xml` and YAML
1097
+ * encodable. The list did not become wrong in this file — it became wrong in
1098
+ * every app scaffolded from it, which is the worst place for a copy to live and
1099
+ * exactly what `wire/media-types.ts` exists to prevent. An `accept` attribute is
1100
+ * a harder case than the prose in `ATTACHMENT_WIRE_NOTE` only in that a comment
1101
+ * can point at a function and an attribute has to CONTAIN the answer; the fix is
1102
+ * the same either way, which is to read the declaration rather than restate it.
1103
+ *
1104
+ * WHY EMIT IT AT ALL, RATHER THAN NOTHING. An absent filter means the full
1105
+ * capability set to `resolveMediaPolicy`, so omitting looks equivalent and is
1106
+ * not, because the tags emitted here never call it. `<kai-file-upload>` (and the
1107
+ * Solid `<FileUpload>`) pass `accept` straight to `<input type="file">` and do no
1108
+ * JS filtering at all — unlike `<kai-chat accept>`, which resolves the policy in
1109
+ * `default-input.tsx`. So on THESE tags an absent attribute is not the capability
1110
+ * set, it is wider than the capability set: the dialog offers `.zip`, the emitted
1111
+ * `toAttachment` stages it, and the encoder throws on a file the picker
1112
+ * volunteered. Deriving the attribute is the only form that tracks the
1113
+ * declaration; omitting it is a third behaviour that matches neither layer.
1114
+ *
1115
+ * IT IS A STEER, NOT A GATE, and nothing here should be read as claiming
1116
+ * otherwise. `accept` filters the OS dialog, which always offers an "All Files"
1117
+ * escape, and it does not apply to drag-and-drop — `file-upload.tsx` hands
1118
+ * `dataTransfer.files` straight to `onFilesAdded`. A scaffolded app that wants a
1119
+ * filter that HOLDS calls `resolveMediaPolicy().decide()` on what it staged; the
1120
+ * emitted note points at it.
1121
+ */
1122
+ const ATTACHMENT_ACCEPT = encodableMediaTypes().join(',');
1123
+
1124
+ /**
1125
+ * What the scaffolder INTENDS to emit for one components list, so a guard can
1126
+ * check the seven renderers against the decision instead of restating it.
1127
+ *
1128
+ * The sibling of `cardEmitPlan`, and exported for the same reason:
1129
+ * `attachmentStagingCheck` in scripts/verify-scaffold-compiles.mjs would
1130
+ * otherwise re-derive "both attachment tags are present" itself, and a guard that
1131
+ * re-derives its own expectation stops being a check of anything the day the two
1132
+ * derivations disagree — it just agrees with itself.
1133
+ *
1134
+ * Unlike `cardEmitPlan` this takes no integration, and that is a real difference
1135
+ * rather than an omission: staging a file needs no model, no tools array and no
1136
+ * route, so `mock` emits exactly what `openai` does. What the wire then makes of
1137
+ * the files is decided by the encoders and by the media-type declaration behind
1138
+ * them, which is the same answer for every integration (see
1139
+ * `ATTACHMENT_WIRE_NOTE`).
1140
+ */
1141
+ export function attachmentEmitPlan(components: readonly string[]): { staging: boolean } {
1142
+ return { staging: hasAttachments(components) };
1143
+ }
1144
+
1145
+ /**
1146
+ * What the emitted attachment surface does, and — the part that matters — what
1147
+ * it does not.
1148
+ *
1149
+ * It stages AND it sends. `toOpenAIMessages` / `toAnthropicMessages` encode
1150
+ * `file` parts into the content blocks each API takes, so a staged file reaches
1151
+ * the model. That is worth stating because it was false for a long time and the
1152
+ * failure was silent: before #186 an attachment-only turn encoded to nothing at
1153
+ * all and was dropped from the request, so a scaffold could put a paperclip on
1154
+ * screen, accept a PDF, render it in the thread, and send the model a message
1155
+ * that never mentioned it.
1156
+ *
1157
+ * WHAT IT STILL DOES NOT DO. Both remaining limits are facts about the APIs, not
1158
+ * gaps in this scaffold, which is why the emitted note can state them plainly:
1159
+ * · Neither API has an arbitrary-file content block, so a media type outside
1160
+ * the kit's set — a `.zip` is the one people try — has no representation on
1161
+ * either wire and throws at encode time rather than going missing.
1162
+ * · A REMOTE text file is refused. Text has to ride as text CONTENT (no wire
1163
+ * has a URL form for it), so inlining one would mean fetching it, and
1164
+ * `wire/` does no I/O by design. Stage a `data:` URI instead.
1165
+ * A file the browser could not name is NOT on that list any more: it is settled
1166
+ * by decoding its bytes, so the `.rs` and `.toml` files a coding chat is full of
1167
+ * go through even though Chrome hands them over with no media type at all.
1168
+ *
1169
+ * ★ THE EMITTED NOTE MUST NOT NAME MEDIA TYPES, and that is the whole reason it
1170
+ * reads the way it does. It lands in a user's repo, where it outlives any
1171
+ * limitation it describes and where nothing can ever check it — a second copy of
1172
+ * the capability list, at the worst possible layer. It points at
1173
+ * `encodableMediaTypes()` instead, which is public precisely so that nobody has
1174
+ * to hardcode the set. A guard in `scaffold.test.ts` fails if a media type
1175
+ * creeps back into it.
1176
+ *
1177
+ * This very comment has been wrong before, in the way that matters most: #186
1178
+ * corrected the emitted string and left the explanation above it teaching the
1179
+ * reverse, so the scaffolder's own documentation said files never reach the
1180
+ * model while the code it emitted said they do. Anything written here that a
1181
+ * script cannot re-derive is a claim with a shelf life.
1182
+ */
1183
+ export const ATTACHMENT_WIRE_NOTE = [
1184
+ '// The staged files ride along on the message as `file` parts, so they RENDER',
1185
+ '// in the thread AND reach the model: toOpenAIMessages / toAnthropicMessages',
1186
+ '// encode each one into the content block its API takes.',
1187
+ '// Which is why toAttachment below stages a `data:` URI rather than a',
1188
+ '// URL.createObjectURL blob: an object URL previews fine and resolves ONLY',
1189
+ '// inside this tab, so the encoder rejects it rather than send an address the',
1190
+ '// provider cannot fetch.',
1191
+ '// What can be sent is not a list worth copying into a comment -- it moves, and',
1192
+ '// a copy cannot. `encodableMediaTypes()` from "@kitn.ai/ui/wire" IS the set,',
1193
+ '// and `resolveMediaPolicy().decide(mediaType)` answers for one file. Anything',
1194
+ '// outside it throws at encode time instead of going missing from the request.',
1195
+ '// A file the browser could not name (a .rs, a .toml) is settled by decoding',
1196
+ '// its bytes, never by its extension.',
1197
+ ];
1198
+
1199
+ /**
1200
+ * The `File` -> `AttachmentData` conversion, emitted as a real function.
1201
+ *
1202
+ * `<kai-file-upload>` hands over `File` objects and `<kai-attachments>` renders
1203
+ * `AttachmentData`, so something has to bridge them and it may as well be code
1204
+ * the consumer can edit.
1205
+ *
1206
+ * It reads the file with `FileReader.readAsDataURL` and stages a `data:` URI —
1207
+ * the same call `readAsDataUrl` in `elements/default-input.tsx` makes, for the
1208
+ * same reason. `URL.createObjectURL` would draw an identical thumbnail and be
1209
+ * meaningless to anything downstream: an object URL resolves only inside the tab
1210
+ * that minted it, so `toOpenAIMessages` / `toAnthropicMessages` refuse it rather
1211
+ * than send a provider an address it cannot fetch. A `data:` URI previews the
1212
+ * same and is the one form both APIs actually take. Reading is async, which is
1213
+ * the only reason the emitted function is.
1214
+ *
1215
+ * WHICH files survive that encoding is deliberately not stated here, and the
1216
+ * emitted code does not state it either: `encodableMediaTypes()` is the set, and
1217
+ * is public precisely so that nothing has to keep a second copy of it (the
1218
+ * reasoning is on `ATTACHMENT_WIRE_NOTE` above).
1219
+ *
1220
+ * The parameter type is derived from the property it feeds
1221
+ * (`KaiAttachmentsElement['items']` / the wrapper's `items` prop) at each call
1222
+ * site rather than importing `AttachmentData`, for the reason the html target
1223
+ * already derives its message type from `KaiChatElement['messages']`: a type
1224
+ * read off the assignment target cannot drift out of step with it.
1225
+ *
1226
+ * This comment taught the reverse until now, and approvingly — it described the
1227
+ * object URL as what made a preview a real thumbnail and called leaving it
1228
+ * unrevoked a deliberate choice, having gone untouched since #185 while #186
1229
+ * rewrote the function under it. That is the second time in this file the code
1230
+ * was corrected and the prose above it was not; the paragraph on
1231
+ * `ATTACHMENT_WIRE_NOTE` records the first. Neither claim had anything to
1232
+ * disagree with, which is the whole hazard of explaining emitted code in prose.
1233
+ */
1234
+ function fileToAttachmentLines(pad: string, typeName: string): string[] {
1235
+ return [
1236
+ `${pad}/** <kai-file-upload> hands over File objects; <kai-attachments> renders these. */`,
1237
+ `${pad}async function toAttachment(file: File): Promise<${typeName}> {`,
1238
+ `${pad} // A data: URI, NOT URL.createObjectURL. Both preview identically, but an`,
1239
+ `${pad} // object URL resolves only inside this tab, so it can never reach a model —`,
1240
+ `${pad} // the encoder rejects one rather than send an address the provider cannot`,
1241
+ `${pad} // fetch. Reading is async, which is why this function is.`,
1242
+ `${pad} const url = await new Promise<string>((resolve, reject) => {`,
1243
+ `${pad} const reader = new FileReader();`,
1244
+ `${pad} reader.onload = () => resolve(String(reader.result));`,
1245
+ `${pad} reader.onerror = () => reject(reader.error ?? new Error('Could not read ' + file.name));`,
1246
+ `${pad} reader.readAsDataURL(file);`,
1247
+ `${pad} });`,
1248
+ `${pad} return {`,
1249
+ `${pad} id: crypto.randomUUID(),`,
1250
+ `${pad} type: 'file',`,
1251
+ `${pad} filename: file.name,`,
1252
+ `${pad} // '' for an extensionless file, and an empty mediaType would render as a`,
1253
+ `${pad} // blank detail line rather than falling back to the generic icon.`,
1254
+ `${pad} mediaType: file.type || undefined,`,
1255
+ `${pad} url,`,
1256
+ `${pad} };`,
1257
+ `${pad}}`,
1258
+ ];
1259
+ }
1260
+
1261
+ /**
1262
+ * Why the submit handler merges TWO sources of staged files.
1263
+ *
1264
+ * `<kai-chat>` renders its own paperclip unconditionally — `ChatThread` always
1265
+ * passes `onAttachmentsChange` to the composer, and there is no prop on the
1266
+ * element that turns it off — so every scaffold ALREADY has an attach button,
1267
+ * and every scaffold before this one dropped whatever was attached through it on
1268
+ * the floor: `kai-submit` carries `{ value, attachments }` and every emitted
1269
+ * handler read only `.value`.
1270
+ *
1271
+ * So reading only the dropzone would leave that bug in place on the one surface
1272
+ * that is supposed to be about attachments. Both are folded in.
1273
+ */
1274
+ const ATTACHMENT_MERGE_NOTE = [
1275
+ "// <kai-chat> renders its OWN paperclip (there is no prop that hides it), and its",
1276
+ '// staged files arrive on this event as detail.attachments. The dropzone above is',
1277
+ '// the second source. Both are folded in — reading either one alone silently',
1278
+ '// drops files the user really attached.',
1279
+ ];
1280
+
1281
+ /**
1282
+ * The lines every framework emits at the TOP of a submit, before the message is
1283
+ * built: capture this turn's attachments into `files`, then empty the staging
1284
+ * list.
1285
+ *
1286
+ * They go through `afterValue` rather than into the message expression because
1287
+ * the order is load-bearing and easy to get backwards — `filesExpr` is spliced
1288
+ * into `parts` AFTER these run, so the capture has to be into a local. Reading
1289
+ * the staging list lazily from `parts` would read it post-clear and attach
1290
+ * nothing, which is precisely the silent failure this whole capability is being
1291
+ * added to stop.
1292
+ *
1293
+ * `fromSubmitEvent` is false for exactly one target: `solid` renders the SolidJS
1294
+ * `PromptInput` primitives directly rather than `<kai-chat>`, so there is no
1295
+ * `kai-submit` event and no second source to merge — its dropzone is the only
1296
+ * one. Merging a `detail` that does not exist there would be a runtime TypeError
1297
+ * on every submit.
1298
+ */
1299
+ function attachmentTurnLines(opts: {
1300
+ /** expression reading the dropzone's staged list */
1301
+ stagedExpr: string;
1302
+ /** statement(s) that empty the staging list (and re-render it) */
1303
+ clear: string[];
1304
+ /** the framework submits through `kai-submit`, so `e.detail.attachments` exists */
1305
+ fromSubmitEvent: boolean;
1306
+ }): string[] {
1307
+ const { stagedExpr, clear, fromSubmitEvent } = opts;
1308
+ return [
1309
+ ...(fromSubmitEvent ? ATTACHMENT_MERGE_NOTE : [
1310
+ '// This target renders PromptInput directly rather than <kai-chat>, so the',
1311
+ '// dropzone is the only source of staged files — there is no kai-submit detail.',
1312
+ ]),
1313
+ fromSubmitEvent
1314
+ ? `const files = [...${stagedExpr}, ...e.detail.attachments];`
1315
+ : `const files = [...${stagedExpr}];`,
1316
+ ...clear,
1317
+ ];
1318
+ }
1319
+
1017
1320
  /**
1018
1321
  * A sample assistant message showing embedded tool + reasoning.
1019
1322
  *
@@ -1136,15 +1439,37 @@ interface RenderCtx {
1136
1439
  *
1137
1440
  * SCAF-14: workspace structural types (kai-resizable, kai-artifact) are emitted
1138
1441
  * as a properly composed split layout — chat in one pane, artifact in the other.
1442
+ *
1443
+ * The attachment pair (kai-file-upload, kai-attachments) is filtered out of the
1444
+ * companion loop for the same reason the workspace pair is: it gets a composed
1445
+ * block of its own, above the chat, with ids the emitted module wires together.
1139
1446
  */
1140
1447
  function componentTags(components: readonly string[], chatFill: string): string {
1141
1448
  const companionTags = components.filter(
1142
- (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
1449
+ (t) =>
1450
+ t !== 'kai-chat' &&
1451
+ !MESSAGE_EMBEDDED_TAGS.has(t) &&
1452
+ !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
1453
+ !(hasAttachments(components) && ATTACHMENT_TAGS.has(t)),
1143
1454
  );
1144
1455
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
1145
1456
  const hasStandaloneCompanions = companionTags.length > 0;
1146
1457
 
1147
1458
  const lines: string[] = [];
1459
+ // The staging strip, ABOVE the thread: drop files in, see them as removable
1460
+ // chips, and they ride out on the next message. `flex: 0 0 auto` keeps it out
1461
+ // of the chat's `flex: 1` claim on the remaining height.
1462
+ if (hasAttachments(components)) {
1463
+ lines.push(
1464
+ ` <!-- Drop files here to stage them for the NEXT message. src/main.ts wires`,
1465
+ ` kai-files-added -> the staged list -> kai-attachments' items property. -->`,
1466
+ ` <div style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem;">`,
1467
+ ` <kai-file-upload id="upload" accept="${ATTACHMENT_ACCEPT}"></kai-file-upload>`,
1468
+ ` <!-- items is a JS PROPERTY (arrays can't be attributes); 'removable' fires kai-remove. -->`,
1469
+ ` <kai-attachments id="attachments" variant="inline" removable></kai-attachments>`,
1470
+ ` </div>`,
1471
+ );
1472
+ }
1148
1473
  // SCAF-14: workspace is a structural/layout surface — emit a runnable split.
1149
1474
  //
1150
1475
  // This used to `return` here, which dropped every standalone companion on the
@@ -1229,6 +1554,49 @@ function componentTags(components: readonly string[], chatFill: string): string
1229
1554
  function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
1230
1555
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
1231
1556
  const hasSources = components.includes('kai-sources');
1557
+ const attachments = hasAttachments(components);
1558
+
1559
+ // MODULE scope, like `model`/`runTool`: the staged list has to outlive `init()`
1560
+ // so the submit handler can read it, and `Staged` is used by the module-scope
1561
+ // `toAttachment` declaration below.
1562
+ const attachmentModuleLines = attachments
1563
+ ? [
1564
+ ...ATTACHMENT_WIRE_NOTE,
1565
+ `//`,
1566
+ `// AttachmentData is the kit's own type for a staged file — the same one`,
1567
+ `// <kai-attachments>.items holds and the same one a 'file' message part holds.`,
1568
+ `let staged: AttachmentData[] = [];`,
1569
+ ``,
1570
+ ...fileToAttachmentLines('', 'AttachmentData'),
1571
+ ``,
1572
+ ]
1573
+ : [];
1574
+
1575
+ const attachmentSetupLines = attachments
1576
+ ? [
1577
+ ` const uploadEl = document.getElementById('upload') as HTMLElement;`,
1578
+ ` const attachmentsEl = document.getElementById('attachments') as KaiAttachmentsElement;`,
1579
+ ` // Same upgrade rule as <kai-chat>: 'items' is an array, so it is a PROPERTY,`,
1580
+ ` // and a property set before the element upgrades is dropped on upgrade.`,
1581
+ ` await customElements.whenDefined('kai-attachments');`,
1582
+ ` // A NEW array per write — same contract as chat.messages.`,
1583
+ ` const showStaged = () => { attachmentsEl.items = [...staged]; };`,
1584
+ ``,
1585
+ ` uploadEl.addEventListener('kai-files-added', async (event: Event) => {`,
1586
+ ` const { files } = (event as CustomEvent<{ files: File[] }>).detail;`,
1587
+ ` // Read every file BEFORE appending: a per-file append would order the`,
1588
+ ` // list by whichever finished reading first.`,
1589
+ ` staged = [...staged, ...(await Promise.all(files.map(toAttachment)))];`,
1590
+ ` showStaged();`,
1591
+ ` });`,
1592
+ ` attachmentsEl.addEventListener('kai-remove', (event: Event) => {`,
1593
+ ` const { id } = (event as CustomEvent<{ id: string }>).detail;`,
1594
+ ` staged = staged.filter((a) => a.id !== id);`,
1595
+ ` showStaged();`,
1596
+ ` });`,
1597
+ ``,
1598
+ ]
1599
+ : [];
1232
1600
 
1233
1601
  // SCAF-9: the agentic archetype explains where tool + reasoning parts come
1234
1602
  // from. It no longer SEEDS a fabricated turn — see `SAMPLE_AGENTIC_MESSAGE`.
@@ -1272,8 +1640,15 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
1272
1640
 
1273
1641
  // KaiSourcesElement only when a kai-sources companion is really declared: a
1274
1642
  // 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';
1643
+ // build error on every other archetype. KaiAttachmentsElement follows the same
1644
+ // rule the `items =` assignment needs it. KaiFileUploadElement is deliberately
1645
+ // NOT imported: the dropzone is only ever listened to, and `as HTMLElement`
1646
+ // types addEventListener fully.
1647
+ const elementTypes = [
1648
+ 'KaiChatElement',
1649
+ ...(hasSources ? ['KaiSourcesElement'] : []),
1650
+ ...(attachments ? ['KaiAttachmentsElement'] : []),
1651
+ ].join(', ');
1277
1652
 
1278
1653
  /**
1279
1654
  * Same rule, applied to the kit's own `ChatMessage`.
@@ -1305,11 +1680,13 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
1305
1680
  cards: ctx.emitCards,
1306
1681
  cardTools: ctx.cardProvider !== null,
1307
1682
  mock: ctx.isMock,
1683
+ attachments,
1308
1684
  }),
1309
1685
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
1310
1686
  ``,
1311
1687
  ...(ctx.isMock ? [...mockResponderInit(), ``] : []),
1312
1688
  ...modelLines,
1689
+ ...attachmentModuleLines,
1313
1690
  ...cardsLines,
1314
1691
  ...toolsLines,
1315
1692
  ...runnerLines,
@@ -1326,14 +1703,21 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
1326
1703
  ...cardPropLines,
1327
1704
  ...seedLines,
1328
1705
  ...sourcesSetupLines,
1706
+ ...attachmentSetupLines,
1329
1707
  ];
1330
1708
 
1331
1709
  // `Event`, not `CustomEvent`: addEventListener with a custom event name hands
1332
1710
  // the listener a plain Event, so the narrowing happens in the body — the same
1333
1711
  // shape renderAngular emits, for the same reason.
1712
+ //
1713
+ // `attachments` is on the detail whether or not this surface stages files —
1714
+ // <kai-chat> always sends both — but it is only NAMED in the cast where the
1715
+ // handler reads it, so every other scaffold keeps the narrower type it uses.
1334
1716
  const listenerOpen = [
1335
1717
  ` chat.addEventListener('kai-submit', async (event: Event) => {`,
1336
- ` const e = event as CustomEvent<{ value: string }>;`,
1718
+ attachments
1719
+ ? ` const e = event as CustomEvent<{ value: string; attachments: AttachmentData[] }>;`
1720
+ : ` const e = event as CustomEvent<{ value: string }>;`,
1337
1721
  ];
1338
1722
  const footer = [
1339
1723
  ` });`,
@@ -1363,6 +1747,16 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
1363
1747
  'chat.messages ?? []',
1364
1748
  ),
1365
1749
  mock: ctx.isMock,
1750
+ ...(attachments
1751
+ ? {
1752
+ filesExpr: 'files',
1753
+ afterValue: attachmentTurnLines({
1754
+ stagedExpr: 'staged',
1755
+ clear: ['staged = [];', 'showStaged();'],
1756
+ fromSubmitEvent: true,
1757
+ }),
1758
+ }
1759
+ : {}),
1366
1760
  }),
1367
1761
  ...footer,
1368
1762
  ].join('\n');
@@ -1438,9 +1832,17 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1438
1832
  const wrapperNames = importTags.map(toPascalCase);
1439
1833
  const importList = wrapperNames.join(', ');
1440
1834
 
1441
- // SCAF-9: standalone companion tags (not kai-chat, not message-embedded, not workspace-structural).
1835
+ const attachments = hasAttachments(components);
1836
+
1837
+ // SCAF-9: standalone companion tags (not kai-chat, not message-embedded, not
1838
+ // workspace-structural, and not the attachment pair — that gets a composed
1839
+ // block of its own with state behind it).
1442
1840
  const standaloneCompanionTags = components.filter(
1443
- (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
1841
+ (t) =>
1842
+ t !== 'kai-chat' &&
1843
+ !MESSAGE_EMBEDDED_TAGS.has(t) &&
1844
+ !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
1845
+ !(attachments && ATTACHMENT_TAGS.has(t)),
1444
1846
  );
1445
1847
 
1446
1848
  // Build companion JSX: only standalone companions with real props.
@@ -1465,6 +1867,59 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1465
1867
  }
1466
1868
  const companions = companionJsxLines.join('\n');
1467
1869
 
1870
+ // The staging strip, ABOVE the chat. `<FileUpload>`/`<Attachments>` are the
1871
+ // generated wrappers, already in `importList` because both tags are in
1872
+ // `components` — no extra import bookkeeping, which is the point of keying the
1873
+ // import list off the components rather than off a per-capability table.
1874
+ const attachmentJsx = attachments
1875
+ ? [
1876
+ ` {/* Drop files here to stage them for the NEXT message. */}`,
1877
+ ` <div style={{ flex: '0 0 auto', display: 'flex', flexDirection: 'column', gap: '0.5rem', padding: '0.75rem' }}>`,
1878
+ ` <FileUpload accept="${ATTACHMENT_ACCEPT}" onFilesAdded={onFilesAdded} />`,
1879
+ ` {/* items is an ARRAY, so the wrapper sets it as a DOM property, never an attribute. */}`,
1880
+ ` <Attachments items={staged} variant="inline" removable onRemove={onRemoveAttachment} />`,
1881
+ ` </div>`,
1882
+ ].join('\n')
1883
+ : '';
1884
+
1885
+ // MODULE scope for `toAttachment` (it closes over nothing); component scope for
1886
+ // the state and the two handlers.
1887
+ const attachmentModuleInit = attachments
1888
+ ? [
1889
+ ...ATTACHMENT_WIRE_NOTE,
1890
+ `//`,
1891
+ `// AttachmentData is the kit's own type for a staged file — the same one`,
1892
+ `// <Attachments>.items holds and the same one a 'file' message part holds.`,
1893
+ ...fileToAttachmentLines('', 'AttachmentData'),
1894
+ ]
1895
+ : [];
1896
+ const attachmentStateInit = attachments
1897
+ ? [
1898
+ ` const [staged, setStaged] = useState<AttachmentData[]>([]);`,
1899
+ ` const onFilesAdded = async (e: CustomEvent<{ files: File[] }>) => {`,
1900
+ ` // Read every file BEFORE appending: a per-file append would order the`,
1901
+ ` // list by whichever finished reading first.`,
1902
+ ` const added = await Promise.all(e.detail.files.map(toAttachment));`,
1903
+ ` setStaged((prev) => [...prev, ...added]);`,
1904
+ ` };`,
1905
+ ` const onRemoveAttachment = (e: CustomEvent<{ id: string }>) =>`,
1906
+ ` setStaged((prev) => prev.filter((a) => a.id !== e.detail.id));`,
1907
+ ].join('\n')
1908
+ : '';
1909
+ const attachmentSubmitOpts = attachments
1910
+ ? {
1911
+ filesExpr: 'files',
1912
+ afterValue: attachmentTurnLines({
1913
+ stagedExpr: 'staged',
1914
+ clear: ['setStaged([]);'],
1915
+ fromSubmitEvent: true,
1916
+ }),
1917
+ }
1918
+ : {};
1919
+ const submitParamType = attachments
1920
+ ? 'CustomEvent<{ value: string; attachments: AttachmentData[] }>'
1921
+ : 'CustomEvent<{ value: string }>';
1922
+
1468
1923
  const mockInit = isMock ? mockResponderInit() : [];
1469
1924
 
1470
1925
  // SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE for the three ways
@@ -1528,6 +1983,7 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1528
1983
  cards: ctx.emitCards,
1529
1984
  thread: REACT_THREAD,
1530
1985
  mock: isMock,
1986
+ ...attachmentSubmitOpts,
1531
1987
  });
1532
1988
 
1533
1989
  // SCAF-2: Next.js App Router requires 'use client' for components that use hooks/interactivity.
@@ -1564,6 +2020,7 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1564
2020
  cards: ctx.emitCards,
1565
2021
  cardTools: ctx.cardProvider !== null,
1566
2022
  mock: isMock,
2023
+ attachments,
1567
2024
  }),
1568
2025
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
1569
2026
  `// <kai-*> are client-only custom elements (the server has no customElements`,
@@ -1576,22 +2033,25 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1576
2033
  ...(p.altNote ?? []).map((l) => `// ${l}`),
1577
2034
  ...mockInit,
1578
2035
  ``,
2036
+ ...attachmentModuleInit,
1579
2037
  ...cardsInit,
1580
2038
  `export default function App() {`,
1581
2039
  sampleMessagesInit,
1582
2040
  ` const [loading, setLoading] = useState(false);`,
1583
2041
  ` const suggestions = ${jsArray(suggestions)};`,
2042
+ ...(attachmentStateInit ? [attachmentStateInit] : []),
1584
2043
  ...(sampleSourcesInit ? [sampleSourcesInit] : []),
1585
2044
  ...(modelInit ? [modelInit] : []),
1586
2045
  ...(toolsInit ? [toolsInit] : []),
1587
2046
  ...(toolRunner ? [toolRunner] : []),
1588
2047
  ``,
1589
- ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
2048
+ ` async function onSubmit(e: ${submitParamType}) {`,
1590
2049
  onSubmitBody,
1591
2050
  ` }`,
1592
2051
  ``,
1593
2052
  ` return (`,
1594
2053
  ` <div style={{ ${jsxStyle(p.style)} }}>`,
2054
+ ...(attachmentJsx ? [attachmentJsx] : []),
1595
2055
  ...(workspace
1596
2056
  ? [
1597
2057
  ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
@@ -1659,6 +2119,7 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1659
2119
  cards: ctx.emitCards,
1660
2120
  cardTools: ctx.cardProvider !== null,
1661
2121
  mock: isMock,
2122
+ attachments,
1662
2123
  }),
1663
2124
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
1664
2125
  ``,
@@ -1667,22 +2128,25 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
1667
2128
  ...(p.altNote ?? []).map((l) => `// ${l}`),
1668
2129
  ...mockInit,
1669
2130
  ``,
2131
+ ...attachmentModuleInit,
1670
2132
  ...cardsInit,
1671
2133
  `export default function App() {`,
1672
2134
  sampleMessagesInit,
1673
2135
  ` const [loading, setLoading] = useState(false);`,
1674
2136
  ` const suggestions = ${jsArray(suggestions)};`,
2137
+ ...(attachmentStateInit ? [attachmentStateInit] : []),
1675
2138
  ...(sampleSourcesInit ? [sampleSourcesInit] : []),
1676
2139
  ...(modelInit ? [modelInit] : []),
1677
2140
  ...(toolsInit ? [toolsInit] : []),
1678
2141
  ...(toolRunner ? [toolRunner] : []),
1679
2142
  ``,
1680
- ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
2143
+ ` async function onSubmit(e: ${submitParamType}) {`,
1681
2144
  onSubmitBody,
1682
2145
  ` }`,
1683
2146
  ``,
1684
2147
  ` return (`,
1685
2148
  ` <div style={{ ${jsxStyle(p.style)} }}>`,
2149
+ ...(attachmentJsx ? [attachmentJsx] : []),
1686
2150
  ...(workspace
1687
2151
  ? [
1688
2152
  ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
@@ -1736,8 +2200,13 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
1736
2200
  // SCAF-9: exclude message-embedded tags from companion rendering.
1737
2201
  // SCAF-14: also exclude workspace structural tags (handled by the workspace block below).
1738
2202
  const workspace = isWorkspace(components);
2203
+ const attachments = hasAttachments(components);
1739
2204
  const standaloneCompanionTags = components.filter(
1740
- (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
2205
+ (t) =>
2206
+ t !== 'kai-chat' &&
2207
+ !MESSAGE_EMBEDDED_TAGS.has(t) &&
2208
+ !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
2209
+ !(attachments && ATTACHMENT_TAGS.has(t)),
1741
2210
  );
1742
2211
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
1743
2212
 
@@ -1758,6 +2227,55 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
1758
2227
  }
1759
2228
  const companions = companionLines.join('\n');
1760
2229
 
2230
+ // `:items.prop` for the same reason `:messages.prop` carries the thread: an
2231
+ // array set as an ATTRIBUTE stringifies, and `<kai-attachments>` would render
2232
+ // the empty state. The listeners are plain `@kai-*` — Vue maps an unknown-tag
2233
+ // event name straight to addEventListener, which is how `@kai-submit` already
2234
+ // works here.
2235
+ const attachmentTemplate = attachments
2236
+ ? [
2237
+ ` <!-- Drop files here to stage them for the NEXT message. -->`,
2238
+ ` <div style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem;">`,
2239
+ ` <kai-file-upload accept="${ATTACHMENT_ACCEPT}" @kai-files-added="onFilesAdded" />`,
2240
+ ` <kai-attachments :items.prop="staged" variant="inline" removable @kai-remove="onRemoveAttachment" />`,
2241
+ ` </div>`,
2242
+ ].join('\n')
2243
+ : '';
2244
+
2245
+ const attachmentScript = attachments
2246
+ ? [
2247
+ ...ATTACHMENT_WIRE_NOTE,
2248
+ `//`,
2249
+ `// AttachmentData is the kit's own type for a staged file, and it is what`,
2250
+ `// <kai-attachments>.items and a 'file' MessagePart both hold.`,
2251
+ `const staged = ref<AttachmentData[]>([]);`,
2252
+ ``,
2253
+ ...fileToAttachmentLines('', 'AttachmentData'),
2254
+ ``,
2255
+ `async function onFilesAdded(event: Event) {`,
2256
+ ` const { files } = (event as CustomEvent<{ files: File[] }>).detail;`,
2257
+ ` // Read every file BEFORE appending: a per-file append would order the`,
2258
+ ` // list by whichever finished reading first.`,
2259
+ ` const added = await Promise.all(files.map(toAttachment));`,
2260
+ ` staged.value = [...staged.value, ...added];`,
2261
+ `}`,
2262
+ `function onRemoveAttachment(event: Event) {`,
2263
+ ` const { id } = (event as CustomEvent<{ id: string }>).detail;`,
2264
+ ` staged.value = staged.value.filter((a) => a.id !== id);`,
2265
+ `}`,
2266
+ ]
2267
+ : [];
2268
+ const attachmentSubmitOpts = attachments
2269
+ ? {
2270
+ filesExpr: 'files',
2271
+ afterValue: attachmentTurnLines({
2272
+ stagedExpr: 'staged.value',
2273
+ clear: ['staged.value = [];'],
2274
+ fromSubmitEvent: true,
2275
+ }),
2276
+ }
2277
+ : {};
2278
+
1761
2279
  const onSubmitBody = realStreamBody({
1762
2280
  pad: ' ',
1763
2281
  read: 'messages.value',
@@ -1770,6 +2288,7 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
1770
2288
  cards: ctx.emitCards,
1771
2289
  thread: liveThreadBinding('messages.value', '(fn) => { messages.value = fn(messages.value); }'),
1772
2290
  mock: isMock,
2291
+ ...attachmentSubmitOpts,
1773
2292
  });
1774
2293
 
1775
2294
  // SCAF-10: ChatMessage declaration for strict-TS Vue consumers.
@@ -1879,6 +2398,7 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
1879
2398
  cards: ctx.emitCards,
1880
2399
  cardTools: ctx.cardProvider !== null,
1881
2400
  mock: isMock,
2401
+ attachments,
1882
2402
  }),
1883
2403
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
1884
2404
  vueImports,
@@ -1893,6 +2413,7 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
1893
2413
  ...toolsLines,
1894
2414
  ...runnerLines,
1895
2415
  ...sourcesSeed,
2416
+ ...attachmentScript,
1896
2417
  ``,
1897
2418
  `// SCAF-15: kai-* register via an async dynamic import (SSR-safety). The .prop`,
1898
2419
  `// bindings can apply before the element upgrades, which drops them — re-apply once`,
@@ -1904,13 +2425,16 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
1904
2425
  ` if (el) Object.assign(el, { messages: messages.value, loading: loading.value, suggestions${cardPropAssign.length ? `, ${cardPropAssign.join(', ')}` : ''} });`,
1905
2426
  `});`,
1906
2427
  ``,
1907
- `async function onSubmit(e: CustomEvent<{ value: string }>) {`,
2428
+ `async function onSubmit(e: ${attachments
2429
+ ? 'CustomEvent<{ value: string; attachments: AttachmentData[] }>'
2430
+ : 'CustomEvent<{ value: string }>'}) {`,
1908
2431
  onSubmitBody,
1909
2432
  `}`,
1910
2433
  `</script>`,
1911
2434
  ``,
1912
2435
  `<template>`,
1913
2436
  ` <div style="${p.style}">`,
2437
+ ...(attachmentTemplate ? [attachmentTemplate] : []),
1914
2438
  ...workspaceTemplate,
1915
2439
  ` </div>`,
1916
2440
  `</template>`,
@@ -1926,8 +2450,13 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
1926
2450
  // SCAF-9: exclude message-embedded tags from companion rendering.
1927
2451
  // SCAF-14: also exclude workspace structural tags (handled by the workspace block below).
1928
2452
  const workspace = isWorkspace(components);
2453
+ const attachments = hasAttachments(components);
1929
2454
  const standaloneCompanionTags = components.filter(
1930
- (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
2455
+ (t) =>
2456
+ t !== 'kai-chat' &&
2457
+ !MESSAGE_EMBEDDED_TAGS.has(t) &&
2458
+ !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
2459
+ !(attachments && ATTACHMENT_TAGS.has(t)),
1931
2460
  );
1932
2461
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
1933
2462
  const hasSourcesCompanion = standaloneCompanionTags.includes('kai-sources');
@@ -1949,6 +2478,17 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
1949
2478
  }
1950
2479
  const companionLines = companionLinesList.join('\n');
1951
2480
 
2481
+ const attachmentSubmitOpts = attachments
2482
+ ? {
2483
+ filesExpr: 'files',
2484
+ afterValue: attachmentTurnLines({
2485
+ stagedExpr: 'staged',
2486
+ clear: ['staged = [];'],
2487
+ fromSubmitEvent: true,
2488
+ }),
2489
+ }
2490
+ : {};
2491
+
1952
2492
  const onSubmitBody = realStreamBody({
1953
2493
  pad: ' ',
1954
2494
  read: 'messages',
@@ -1961,6 +2501,7 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
1961
2501
  cards: ctx.emitCards,
1962
2502
  thread: liveThreadBinding('messages', '(fn) => { messages = fn(messages); }'),
1963
2503
  mock: isMock,
2504
+ ...attachmentSubmitOpts,
1964
2505
  });
1965
2506
 
1966
2507
  // SCAF-10: ChatMessage declaration for strict-TS Svelte consumers.
@@ -2021,6 +2562,43 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
2021
2562
  ]
2022
2563
  : [];
2023
2564
 
2565
+ // Svelte 5: the element ref is $state (bind:this writes to it), and the staged
2566
+ // list is $state.raw for the same reason `messages` is — a NEW array per write is
2567
+ // the kit's contract, and deep state would proxy every object on its way into a
2568
+ // Solid-backed custom element. `items` is an ARRAY, so it is applied through the
2569
+ // same upgrade-gated $effect chatEl.messages goes through, never as an attribute.
2570
+ const attachmentScript = attachments
2571
+ ? [
2572
+ ...ATTACHMENT_WIRE_NOTE.map((l) => ` ${l}`),
2573
+ ` //`,
2574
+ ` // AttachmentData is the kit's own type for a staged file, and it is what`,
2575
+ ` // <kai-attachments>.items and a 'file' MessagePart both hold.`,
2576
+ ` let attachmentsEl = $state<KaiAttachmentsElement | undefined>(undefined);`,
2577
+ ` let staged = $state.raw<AttachmentData[]>([]);`,
2578
+ ` $effect(() => { if (attachmentsEl && defined) { attachmentsEl.items = staged; } });`,
2579
+ ``,
2580
+ ...fileToAttachmentLines(' ', 'AttachmentData'),
2581
+ ` async function onFilesAdded(e: CustomEvent<{ files: File[] }>) {`,
2582
+ ` // Read every file BEFORE appending: a per-file append would order the`,
2583
+ ` // list by whichever finished reading first.`,
2584
+ ` const added = await Promise.all(e.detail.files.map(toAttachment));`,
2585
+ ` staged = [...staged, ...added];`,
2586
+ ` }`,
2587
+ ` function onRemoveAttachment(e: CustomEvent<{ id: string }>) {`,
2588
+ ` staged = staged.filter((a) => a.id !== e.detail.id);`,
2589
+ ` }`,
2590
+ ]
2591
+ : [];
2592
+ const attachmentMarkup = attachments
2593
+ ? [
2594
+ ` <!-- Drop files here to stage them for the NEXT message. -->`,
2595
+ ` <div style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem;">`,
2596
+ ` <kai-file-upload accept="${ATTACHMENT_ACCEPT}" onkai-files-added={onFilesAdded}></kai-file-upload>`,
2597
+ ` <kai-attachments bind:this={attachmentsEl} variant="inline" removable onkai-remove={onRemoveAttachment}></kai-attachments>`,
2598
+ ` </div>`,
2599
+ ]
2600
+ : [];
2601
+
2024
2602
  // SCAF-14: workspace template block — resizable split with chat + artifact panes.
2025
2603
  const workspaceMarkup = workspace
2026
2604
  ? [
@@ -2055,7 +2633,11 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
2055
2633
  // KaiSourcesElement is only imported when a kai-sources companion is actually
2056
2634
  // declared below: an always-on import would be unused (and fail noUnusedLocals)
2057
2635
  // on every archetype without kai-sources.
2058
- ` import type { ${hasSourcesCompanion ? 'KaiChatElement, KaiSourcesElement' : 'KaiChatElement'} } from '@kitn.ai/ui/elements';`,
2636
+ ` import type { ${[
2637
+ 'KaiChatElement',
2638
+ ...(hasSourcesCompanion ? ['KaiSourcesElement'] : []),
2639
+ ...(attachments ? ['KaiAttachmentsElement'] : []),
2640
+ ].join(', ')} } from '@kitn.ai/ui/elements';`,
2059
2641
  ...wireImportLines({
2060
2642
  pad: ' ',
2061
2643
  typed: true,
@@ -2063,6 +2645,7 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
2063
2645
  cards: ctx.emitCards,
2064
2646
  cardTools: ctx.cardProvider !== null,
2065
2647
  mock: isMock,
2648
+ attachments,
2066
2649
  }),
2067
2650
  ` import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
2068
2651
  ` import { onMount } from 'svelte';`,
@@ -2088,13 +2671,17 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
2088
2671
  ` if (chatEl && defined) { chatEl.messages = messages; chatEl.loading = loading; chatEl.suggestions = suggestions;${cardPropEffect} }`,
2089
2672
  ` });`,
2090
2673
  ...sourcesReactive,
2674
+ ...attachmentScript,
2091
2675
  ``,
2092
- ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
2676
+ ` async function onSubmit(e: ${attachments
2677
+ ? 'CustomEvent<{ value: string; attachments: AttachmentData[] }>'
2678
+ : 'CustomEvent<{ value: string }>'}) {`,
2093
2679
  onSubmitBody,
2094
2680
  ` }`,
2095
2681
  `</script>`,
2096
2682
  ``,
2097
2683
  `<div style="${p.style}">`,
2684
+ ...attachmentMarkup,
2098
2685
  ...workspaceMarkup,
2099
2686
  `</div>`,
2100
2687
  ]
@@ -2151,8 +2738,14 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2151
2738
  const wrapperNames = importTags.map(toPascalCase);
2152
2739
  const importList = wrapperNames.join(', ');
2153
2740
 
2741
+ const attachments = hasAttachments(components);
2742
+
2154
2743
  const standaloneCompanionTags = components.filter(
2155
- (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
2744
+ (t) =>
2745
+ t !== 'kai-chat' &&
2746
+ !MESSAGE_EMBEDDED_TAGS.has(t) &&
2747
+ !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
2748
+ !(attachments && ATTACHMENT_TAGS.has(t)),
2156
2749
  );
2157
2750
 
2158
2751
  const companionJsxLines: string[] = [];
@@ -2175,6 +2768,54 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2175
2768
  }
2176
2769
  const companions = companionJsxLines.join('\n');
2177
2770
 
2771
+ // Same three pieces as `renderJsx` — this target IS React. See there for why
2772
+ // the type comes from @kitn.ai/ui/state and why the strip sits above the chat.
2773
+ const attachmentJsx = attachments
2774
+ ? [
2775
+ ` {/* Drop files here to stage them for the NEXT message. */}`,
2776
+ ` <div style={{ flex: '0 0 auto', display: 'flex', flexDirection: 'column', gap: '0.5rem', padding: '0.75rem' }}>`,
2777
+ ` <FileUpload accept="${ATTACHMENT_ACCEPT}" onFilesAdded={onFilesAdded} />`,
2778
+ ` {/* items is an ARRAY, so the wrapper sets it as a DOM property, never an attribute. */}`,
2779
+ ` <Attachments items={staged} variant="inline" removable onRemove={onRemoveAttachment} />`,
2780
+ ` </div>`,
2781
+ ].join('\n')
2782
+ : '';
2783
+ const attachmentModuleInit = attachments
2784
+ ? [
2785
+ ...ATTACHMENT_WIRE_NOTE,
2786
+ `//`,
2787
+ `// AttachmentData is the kit's own type for a staged file — the same one`,
2788
+ `// <Attachments>.items holds and the same one a 'file' message part holds.`,
2789
+ ...fileToAttachmentLines('', 'AttachmentData'),
2790
+ ]
2791
+ : [];
2792
+ const attachmentStateInit = attachments
2793
+ ? [
2794
+ ` const [staged, setStaged] = useState<AttachmentData[]>([]);`,
2795
+ ` const onFilesAdded = async (e: CustomEvent<{ files: File[] }>) => {`,
2796
+ ` // Read every file BEFORE appending: a per-file append would order the`,
2797
+ ` // list by whichever finished reading first.`,
2798
+ ` const added = await Promise.all(e.detail.files.map(toAttachment));`,
2799
+ ` setStaged((prev) => [...prev, ...added]);`,
2800
+ ` };`,
2801
+ ` const onRemoveAttachment = (e: CustomEvent<{ id: string }>) =>`,
2802
+ ` setStaged((prev) => prev.filter((a) => a.id !== e.detail.id));`,
2803
+ ].join('\n')
2804
+ : '';
2805
+ const attachmentSubmitOpts = attachments
2806
+ ? {
2807
+ filesExpr: 'files',
2808
+ afterValue: attachmentTurnLines({
2809
+ stagedExpr: 'staged',
2810
+ clear: ['setStaged([]);'],
2811
+ fromSubmitEvent: true,
2812
+ }),
2813
+ }
2814
+ : {};
2815
+ const submitParamType = attachments
2816
+ ? 'CustomEvent<{ value: string; attachments: AttachmentData[] }>'
2817
+ : 'CustomEvent<{ value: string }>';
2818
+
2178
2819
  const mockInit = isMock ? mockResponderInit() : [];
2179
2820
 
2180
2821
  // SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE.
@@ -2227,6 +2868,7 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2227
2868
  cards: ctx.emitCards,
2228
2869
  thread: REACT_THREAD,
2229
2870
  mock: isMock,
2871
+ ...attachmentSubmitOpts,
2230
2872
  });
2231
2873
 
2232
2874
  // File path guidance for TanStack Start (file-based routing)
@@ -2260,6 +2902,7 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2260
2902
  cards: ctx.emitCards,
2261
2903
  cardTools: ctx.cardProvider !== null,
2262
2904
  mock: isMock,
2905
+ attachments,
2263
2906
  }),
2264
2907
  `import '@kitn.ai/ui/theme.tokens.css' // compiled token defaults`,
2265
2908
  ``,
@@ -2267,6 +2910,7 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2267
2910
  ...(p.altNote ?? []).map((l) => `// ${l}`),
2268
2911
  ...mockInit,
2269
2912
  ``,
2913
+ ...attachmentModuleInit,
2270
2914
  ...cardsInit,
2271
2915
  `// ssr: false keeps the Solid-based web component client-only.`,
2272
2916
  `// Server HTML for /chat omits <kai-chat> → no hydration mismatch.`,
@@ -2279,17 +2923,19 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
2279
2923
  sampleMessagesInit,
2280
2924
  ` const [loading, setLoading] = useState(false);`,
2281
2925
  ` const suggestions = ${jsArray(suggestions)};`,
2926
+ ...(attachmentStateInit ? [attachmentStateInit] : []),
2282
2927
  ...(sampleSourcesInit ? [sampleSourcesInit] : []),
2283
2928
  ...(modelInit ? [modelInit] : []),
2284
2929
  ...(toolsInit ? [toolsInit] : []),
2285
2930
  ...(toolRunner ? [toolRunner] : []),
2286
2931
  ``,
2287
- ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
2932
+ ` async function onSubmit(e: ${submitParamType}) {`,
2288
2933
  onSubmitBody,
2289
2934
  ` }`,
2290
2935
  ``,
2291
2936
  ` return (`,
2292
2937
  ` <main style={{ ${jsxStyle(p.style)} }}>`,
2938
+ ...(attachmentJsx ? [attachmentJsx] : []),
2293
2939
  ...(workspace
2294
2940
  ? [
2295
2941
  ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
@@ -2366,8 +3012,13 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
2366
3012
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
2367
3013
 
2368
3014
  const workspace = isWorkspace(components);
3015
+ const attachments = hasAttachments(components);
2369
3016
  const standaloneCompanionTags = components.filter(
2370
- (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
3017
+ (t) =>
3018
+ t !== 'kai-chat' &&
3019
+ !MESSAGE_EMBEDDED_TAGS.has(t) &&
3020
+ !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
3021
+ !(attachments && ATTACHMENT_TAGS.has(t)),
2371
3022
  );
2372
3023
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
2373
3024
  const hasSourcesCompanion = standaloneCompanionTags.includes('kai-sources');
@@ -2390,6 +3041,61 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
2390
3041
  }
2391
3042
  }
2392
3043
 
3044
+ // `[items]` is a PROPERTY binding for the same reason `[messages]` is: an array
3045
+ // written as an attribute stringifies. Angular's own signal read goes in the
3046
+ // template, so no afterNextRender re-apply is needed for it — the binding
3047
+ // re-runs on every signal change, including the first one after upgrade.
3048
+ const attachmentTemplate = attachments
3049
+ ? [
3050
+ ` <!-- Drop files here to stage them for the NEXT message. -->`,
3051
+ ` <div style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem;">`,
3052
+ ` <kai-file-upload accept="${ATTACHMENT_ACCEPT}" (kai-files-added)="onFilesAdded($event)"></kai-file-upload>`,
3053
+ ` <kai-attachments [items]="staged()" variant="inline" removable (kai-remove)="onRemoveAttachment($event)"></kai-attachments>`,
3054
+ ` </div>`,
3055
+ ]
3056
+ : [];
3057
+ // Module scope for `toAttachment` (a class holds no bare function declaration),
3058
+ // class scope for the signal and the two handlers the template calls.
3059
+ const attachmentModuleInit = attachments
3060
+ ? [
3061
+ ...ATTACHMENT_WIRE_NOTE,
3062
+ `//`,
3063
+ `// AttachmentData is the kit's own type for a staged file, and it is what`,
3064
+ `// <kai-attachments>.items and a 'file' MessagePart both hold.`,
3065
+ ...fileToAttachmentLines('', 'AttachmentData'),
3066
+ ``,
3067
+ ]
3068
+ : [];
3069
+ const attachmentFields = attachments
3070
+ ? [
3071
+ ` readonly staged = signal<AttachmentData[]>([]);`,
3072
+ ``,
3073
+ ` // Same Event-not-CustomEvent rule as onSubmit below: strictTemplates types`,
3074
+ ` // $event on an unknown custom-element event as a plain Event.`,
3075
+ ` async onFilesAdded(event: Event) {`,
3076
+ ` const { files } = (event as CustomEvent<{ files: File[] }>).detail;`,
3077
+ ` // Read every file BEFORE appending: a per-file append would order the`,
3078
+ ` // list by whichever finished reading first.`,
3079
+ ` const added = await Promise.all(files.map(toAttachment));`,
3080
+ ` this.staged.set([...this.staged(), ...added]);`,
3081
+ ` }`,
3082
+ ` onRemoveAttachment(event: Event) {`,
3083
+ ` const { id } = (event as CustomEvent<{ id: string }>).detail;`,
3084
+ ` this.staged.set(this.staged().filter((a) => a.id !== id));`,
3085
+ ` }`,
3086
+ ]
3087
+ : [];
3088
+ const attachmentSubmitOpts = attachments
3089
+ ? {
3090
+ filesExpr: 'files',
3091
+ afterValue: attachmentTurnLines({
3092
+ stagedExpr: 'this.staged()',
3093
+ clear: ['this.staged.set([]);'],
3094
+ fromSubmitEvent: true,
3095
+ }),
3096
+ }
3097
+ : {};
3098
+
2393
3099
  // Angular signals: `this.messages()` reads, `this.messages.set(next)` writes a
2394
3100
  // BRAND-NEW array, which is what re-renders <kai-chat>.
2395
3101
  const read = 'this.messages()';
@@ -2408,6 +3114,7 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
2408
3114
  cards: ctx.emitCards,
2409
3115
  thread: accessorThreadBinding(read, commit, setter),
2410
3116
  mock: isMock,
3117
+ ...attachmentSubmitOpts,
2411
3118
  });
2412
3119
 
2413
3120
  // Module scope, exactly like vue: a class can hold neither a bare `const` nor a
@@ -2495,6 +3202,9 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
2495
3202
  // declared: an always-on import is unused on every other archetype, and a stock
2496
3203
  // Angular tsconfig turns on the checks that make that a build error.
2497
3204
  const elementTypes = hasSourcesCompanion ? 'KaiChatElement, KaiSourcesElement' : 'KaiChatElement';
3205
+ // No KaiAttachmentsElement here: unlike svelte/html this target never holds an
3206
+ // element reference for the list — `[items]` is a template binding, so nothing
3207
+ // in the class is typed by it.
2498
3208
 
2499
3209
  return [
2500
3210
  `// Angular standalone component — save as: src/app/chat.component.ts`,
@@ -2516,6 +3226,7 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
2516
3226
  cards: ctx.emitCards,
2517
3227
  cardTools: ctx.cardProvider !== null,
2518
3228
  mock: isMock,
3229
+ attachments,
2519
3230
  }),
2520
3231
  ``,
2521
3232
  `// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
@@ -2523,6 +3234,7 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
2523
3234
  ...(isMock ? mockResponderInit() : []),
2524
3235
  ``,
2525
3236
  ...modelInit,
3237
+ ...attachmentModuleInit,
2526
3238
  ...cardsInit,
2527
3239
  ...toolsLines,
2528
3240
  ...runnerLines,
@@ -2534,6 +3246,7 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
2534
3246
  ` schemas: [CUSTOM_ELEMENTS_SCHEMA],`,
2535
3247
  ` template: \``,
2536
3248
  ` <div style="${p.style}">`,
3249
+ ...attachmentTemplate,
2537
3250
  ...templateBody,
2538
3251
  ` </div>`,
2539
3252
  ` \`,`,
@@ -2566,11 +3279,13 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
2566
3279
  ` });`,
2567
3280
  ` }`,
2568
3281
  ``,
3282
+ ...attachmentFields,
3283
+ ``,
2569
3284
  ` // \`Event\`, not \`CustomEvent\`: under strictTemplates Angular types \`$event\` on`,
2570
3285
  ` // an unknown custom-element event as a plain Event, so the narrowing happens`,
2571
3286
  ` // here rather than in the signature.`,
2572
3287
  ` async onSubmit(event: Event) {`,
2573
- ` const e = event as CustomEvent<{ value: string }>;`,
3288
+ ` const e = event as CustomEvent<{ value: string${attachments ? '; attachments: AttachmentData[]' : ''} }>;`,
2574
3289
  onSubmitBody,
2575
3290
  ` }`,
2576
3291
  `}`,
@@ -2625,8 +3340,13 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
2625
3340
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
2626
3341
 
2627
3342
  const workspace = isWorkspace(components);
3343
+ const attachments = hasAttachments(components);
2628
3344
  const standaloneCompanionTags = components.filter(
2629
- (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
3345
+ (t) =>
3346
+ t !== 'kai-chat' &&
3347
+ !MESSAGE_EMBEDDED_TAGS.has(t) &&
3348
+ !WORKSPACE_STRUCTURAL_TAGS.has(t) &&
3349
+ !(attachments && ATTACHMENT_TAGS.has(t)),
2630
3350
  );
2631
3351
  const hasSources = standaloneCompanionTags.includes('kai-sources');
2632
3352
  const hasVoice = standaloneCompanionTags.includes('kai-voice-input');
@@ -2669,6 +3389,12 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
2669
3389
  'Tool',
2670
3390
  ...(workspace ? ['Artifact', 'ResizableHandle', 'ResizablePanel', 'ResizablePanelGroup'] : []),
2671
3391
  ...(hasVoice ? ['VoiceInput'] : []),
3392
+ // `Attachments`/`Attachment`/`AttachmentPreview`/`AttachmentInfo` are already
3393
+ // unconditional above — `renderPart` draws `file` PARTS with them whatever the
3394
+ // surface asked for. These three are the STAGING half, which only this
3395
+ // capability composes: the dropzone, its trigger, and the per-chip remove
3396
+ // button `renderPart` has no use for (a sent file is not removable).
3397
+ ...(attachments ? ['AttachmentRemove', 'FileUpload', 'FileUploadTrigger'] : []),
2672
3398
  ].sort();
2673
3399
 
2674
3400
  // Solid signals: `messages()` reads, `setMessages(next)` writes a NEW array.
@@ -2691,7 +3417,19 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
2691
3417
  cards: ctx.emitCards,
2692
3418
  thread: accessorThreadBinding(read, commit, setter),
2693
3419
  valueSource: 'input()',
2694
- afterValue: [`setInput('');`],
3420
+ // The staged files are captured BEFORE either list is cleared. `setInput('')`
3421
+ // stays last so the order reads the way it executes.
3422
+ afterValue: [
3423
+ ...(attachments
3424
+ ? attachmentTurnLines({
3425
+ stagedExpr: 'staged()',
3426
+ clear: ['setStaged([]);'],
3427
+ fromSubmitEvent: false,
3428
+ })
3429
+ : []),
3430
+ `setInput('');`,
3431
+ ],
3432
+ ...(attachments ? { filesExpr: 'files' } : {}),
2695
3433
  mock: isMock,
2696
3434
  });
2697
3435
 
@@ -2718,6 +3456,17 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
2718
3456
  ]
2719
3457
  : [];
2720
3458
 
3459
+ // The staging state. `toAttachment` is declared INSIDE App() here, unlike the
3460
+ // kai-* targets: this file's module scope is where `renderPart` and the card
3461
+ // registry live, and nothing outside the component needs it.
3462
+ const attachmentInit = attachments
3463
+ ? [
3464
+ ...ATTACHMENT_WIRE_NOTE.map((l) => ` ${l}`),
3465
+ ` const [staged, setStaged] = createSignal<AttachmentData[]>([]);`,
3466
+ ...fileToAttachmentLines(' ', 'AttachmentData'),
3467
+ ]
3468
+ : [];
3469
+
2721
3470
  const sourcesInit = hasSources
2722
3471
  ? [
2723
3472
  ` // Replace sampleSources with your real source data.`,
@@ -2802,6 +3551,37 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
2802
3551
  ` </For>`,
2803
3552
  ` </div>`,
2804
3553
  ` </Show>`,
3554
+ ...(attachments
3555
+ ? [
3556
+ ` {/* Drop files here to stage them for the NEXT message. */}`,
3557
+ ` <div class="flex flex-col gap-2 pb-3">`,
3558
+ ` {/* Read every file BEFORE appending: a per-file append would order`,
3559
+ ` the list by whichever finished reading first. */}`,
3560
+ ` <FileUpload`,
3561
+ ` accept="${ATTACHMENT_ACCEPT}"`,
3562
+ ` onFilesAdded={async (files) => setStaged([...staged(), ...(await Promise.all(files.map(toAttachment)))])}`,
3563
+ ` >`,
3564
+ ` <FileUploadTrigger class="border-border text-muted-foreground w-full rounded-xl border border-dashed px-4 py-3 text-center text-sm">`,
3565
+ ` Click or drop files to attach`,
3566
+ ` </FileUploadTrigger>`,
3567
+ ` </FileUpload>`,
3568
+ ` {/* The staged chips. onRemove is what makes <AttachmentRemove> draw its button. */}`,
3569
+ ` <Show when={staged().length > 0}>`,
3570
+ ` <Attachments variant="inline">`,
3571
+ ` <For each={staged()}>`,
3572
+ ` {(a) => (`,
3573
+ ` <Attachment data={a} onRemove={() => setStaged(staged().filter((x) => x.id !== a.id))}>`,
3574
+ ` <AttachmentPreview />`,
3575
+ ` <AttachmentInfo />`,
3576
+ ` <AttachmentRemove />`,
3577
+ ` </Attachment>`,
3578
+ ` )}`,
3579
+ ` </For>`,
3580
+ ` </Attachments>`,
3581
+ ` </Show>`,
3582
+ ` </div>`,
3583
+ ]
3584
+ : []),
2805
3585
  ` <PromptInput value={input()} onValueChange={setInput} onSubmit={onSubmit} isLoading={loading()}>`,
2806
3586
  ` <div class="flex flex-col">`,
2807
3587
  ` <PromptInputTextarea placeholder="Send a message…" class="min-h-[44px] pt-3 pl-4" />`,
@@ -2872,11 +3652,27 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
2872
3652
  `// every kit utility class as unused, and the whole UI renders unstyled.`,
2873
3653
  `// (theme.css here, not theme.tokens.css: this app compiles Tailwind itself.)`,
2874
3654
  `import { For, Index, Match, Show, Switch, createMemo, createSignal } from 'solid-js';`,
3655
+ // '@kitn.ai/ui/solid', NOT the root '@kitn.ai/ui', and the difference is
3656
+ // invisible to every compiler on this repo's critical path: src/solid.ts is
3657
+ // `export * from './index'`, so ./solid is a strict SUPERSET of the root and
3658
+ // both specifiers typecheck identically. verify:scaffold's solid project
3659
+ // compiled clean the whole time this was wrong.
3660
+ //
3661
+ // The root is the shared layer EVERY consumer resolves — React, Vue, Svelte
3662
+ // and vanilla included — so it deliberately carries only the chat components,
3663
+ // types and helpers those frameworks can use. The rest of the Solid catalog
3664
+ // lives on ./solid, built as its own bundle so a React app never pays for it.
3665
+ // A Solid app pointed at the root therefore gets the smaller surface and finds
3666
+ // out the hard way, one missing export at a time. The published guide says so
3667
+ // outright ("Import Solid components from @kitn.ai/ui/solid, not from the root
3668
+ // @kitn.ai/ui"), and this generator contradicted it for every Solid scaffold.
2875
3669
  `import {`,
2876
3670
  ...componentImports.map((n) => ` ${n},`),
2877
- `} from '@kitn.ai/ui';`,
3671
+ `} from '@kitn.ai/ui/solid';`,
2878
3672
  `// The kit's own types, from the same entry the components come from.`,
2879
- `import type { ChatMessage, MessagePart, MessageSource } from '@kitn.ai/ui';`,
3673
+ `import type { ${attachments
3674
+ ? 'AttachmentData, ChatMessage, MessagePart, MessageSource'
3675
+ : 'ChatMessage, MessagePart, MessageSource'} } from '@kitn.ai/ui/solid';`,
2880
3676
  ...wireImportLines({
2881
3677
  typed: false,
2882
3678
  toolLoop: emitToolLoop,
@@ -3080,6 +3876,7 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
3080
3876
  ` // value, no row moves, and the content updates through accessors instead.`,
3081
3877
  ` const messageKeys = createMemo(() => messages().map((m) => m.id));`,
3082
3878
  ` const suggestions = ${jsArray(suggestions)};`,
3879
+ ...attachmentInit,
3083
3880
  ...sourcesInit,
3084
3881
  ...modelInit,
3085
3882
  ...toolsLines,
@@ -3159,7 +3956,7 @@ export interface SurfaceRequest {
3159
3956
  * The `kai-*` components this surface composes — THE AXIS, not an archetype id.
3160
3957
  *
3161
3958
  * 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
3959
+ * archetypes are seven points in this space and the CLI's feature multi-select
3163
3960
  * reaches the rest of it, so a renderer keyed on a preset id could only ever
3164
3961
  * emit what someone had already thought to name. `kai-chat` is expected to be
3165
3962
  * present; everything else is a capability.
@@ -3718,6 +4515,85 @@ const CHAT_REQUEST_BODY_DECL = [
3718
4515
  `}`,
3719
4516
  ];
3720
4517
 
4518
+ /**
4519
+ * Attachments, on the way IN.
4520
+ *
4521
+ * A user turn's `content` is a plain string until it carries a file, at which
4522
+ * point `toOpenAIMessages` emits the ARRAY form. Every route that re-maps
4523
+ * messages into some other SDK's shape has to handle both, and the three that do
4524
+ * (anthropic, mastra, vercel-ai-sdk) were each written when only the string form
4525
+ * existed — so each one would have quietly dropped the attachment while still
4526
+ * compiling, which is the same defect the encoder was just fixed for.
4527
+ *
4528
+ * These two helpers are the shared half of that: flattening the wire shape is
4529
+ * identical everywhere, while the target shape is not, so each route maps
4530
+ * `WirePart[]` into its own SDK itself rather than inheriting a lowest common
4531
+ * denominator.
4532
+ *
4533
+ * Only injected into routes that actually call them — the eight pass-through
4534
+ * integrations forward `messages` untouched and need none of this, and an unused
4535
+ * declaration is a hard error under the gate's `--noUnusedLocals`.
4536
+ */
4537
+ const CONTENT_PARTS_DECL = [
4538
+ `/** Where an attachment's bytes are: inline base64, or an address the PROVIDER`,
4539
+ ` * fetches. Never both. */`,
4540
+ `type WireFileSource = { type: 'data'; data: string } | { type: 'url'; url: string };`,
4541
+ ``,
4542
+ `/** One piece of a turn, with the string and array content forms flattened into`,
4543
+ ` * a single shape. */`,
4544
+ `type WirePart =`,
4545
+ ` | { kind: 'text'; text: string }`,
4546
+ ` | { kind: 'file'; mediaType: string; filename?: string; source: WireFileSource };`,
4547
+ ``,
4548
+ `const DATA_URI = /^data:([^;,]+);base64,([\\s\\S]*)$/;`,
4549
+ ``,
4550
+ `/**`,
4551
+ ` * Flatten a wire message's content into parts.`,
4552
+ ` *`,
4553
+ ` * An image sent by URL has no media type here — \`image_url\` carries only the`,
4554
+ ` * address — so it reports the top-level segment \`'image'\`, which is all a URL`,
4555
+ ` * source needs. Only images can reach that branch: the kit refuses to encode a`,
4556
+ ` * remote PDF rather than guess at one.`,
4557
+ ` */`,
4558
+ `function wireParts(content: OpenAIWireMessage['content']): WirePart[] {`,
4559
+ ` if (content == null) return [];`,
4560
+ ` if (typeof content === 'string') return content === '' ? [] : [{ kind: 'text', text: content }];`,
4561
+ ` return content.map((part): WirePart => {`,
4562
+ ` if (part.type === 'text') return { kind: 'text', text: part.text };`,
4563
+ ` if (part.type === 'image_url') {`,
4564
+ ` const asData = DATA_URI.exec(part.image_url.url);`,
4565
+ ` return asData`,
4566
+ ` ? { kind: 'file', mediaType: asData[1], source: { type: 'data', data: asData[2] } }`,
4567
+ ` : { kind: 'file', mediaType: 'image', source: { type: 'url', url: part.image_url.url } };`,
4568
+ ` }`,
4569
+ ` const asData = DATA_URI.exec(part.file.file_data);`,
4570
+ ` if (!asData) {`,
4571
+ ` // LOUD on purpose. \`file_data\` is a data URI on this wire; anything else`,
4572
+ ` // cannot be turned into bytes without fetching it, and forwarding a turn`,
4573
+ ` // with the attachment quietly missing is the bug this whole path exists`,
4574
+ ` // to prevent.`,
4575
+ ` throw new Error(`,
4576
+ ` 'Unsupported file content part: file_data must be a data: URI of the form data:<media type>;base64,<data>.',`,
4577
+ ` );`,
4578
+ ` }`,
4579
+ ` return {`,
4580
+ ` kind: 'file',`,
4581
+ ` mediaType: asData[1],`,
4582
+ ` filename: part.file.filename,`,
4583
+ ` source: { type: 'data', data: asData[2] },`,
4584
+ ` };`,
4585
+ ` });`,
4586
+ `}`,
4587
+ ``,
4588
+ `/** Just the text of a turn. System, assistant and tool messages are text-only`,
4589
+ ` * on this wire, so this collapses the array form for them. */`,
4590
+ `function wireText(content: OpenAIWireMessage['content']): string {`,
4591
+ ` return wireParts(content)`,
4592
+ ` .map((p) => (p.kind === 'text' ? p.text : ''))`,
4593
+ ` .join('');`,
4594
+ `}`,
4595
+ ];
4596
+
3721
4597
  /**
3722
4598
  * Slot the body type in just above `chatHandler`.
3723
4599
  *
@@ -3728,11 +4604,16 @@ const CHAT_REQUEST_BODY_DECL = [
3728
4604
  * written it.
3729
4605
  */
3730
4606
  function withChatRequestBody(fragment: string): string {
4607
+ // The content helpers ride along only where the route calls them; see
4608
+ // CONTENT_PARTS_DECL for why an unconditional injection would not compile.
4609
+ const decl = /\bwire(?:Parts|Text)\s*\(/.test(fragment)
4610
+ ? [...CHAT_REQUEST_BODY_DECL, ``, ...CONTENT_PARTS_DECL]
4611
+ : CHAT_REQUEST_BODY_DECL;
3731
4612
  const lines = fragment.split('\n');
3732
4613
  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');
4614
+ if (at < 0) return [...decl, ``, ...lines].join('\n');
3734
4615
  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');
4616
+ return [...lines.slice(0, at), ...decl, ``, ...lines.slice(at)].join('\n');
3736
4617
  }
3737
4618
 
3738
4619
  /** Wrap an integration's portable handler in the target framework's declaration. */
@@ -4342,7 +5223,7 @@ function rejectUseCase(id: string): string {
4342
5223
  ``,
4343
5224
  `Valid useCases (presets): ${valid}.`,
4344
5225
  ``,
4345
- // The presets are six points, not the space. A harness that only ever learns
5226
+ // The presets are seven points, not the space. A harness that only ever learns
4346
5227
  // the six ids will ask for the nearest one instead of the surface it wants,
4347
5228
  // so the rejection that teaches the id list is the right place to say so.
4348
5229
  `These are PRESETS over the real axis, which is \`components\`. To compose a surface no`,
@@ -4368,14 +5249,14 @@ export const scaffold: Tool = {
4368
5249
  // catalogs to discover valid ids.
4369
5250
  //
4370
5251
  // `useCase` is OPTIONAL because `components` can carry the surface instead —
4371
- // the archetypes are six points in the components space, not the space. A
5252
+ // the archetypes are seven points in the components space, not the space. A
4372
5253
  // request must still name one of the two, and the handler says so when it
4373
5254
  // names neither.
4374
5255
  useCase: z
4375
5256
  .string()
4376
5257
  .optional()
4377
5258
  .describe(
4378
- 'Archetype PRESET id, e.g. "drop-in-chat", "support-widget", "knowledge-base", "agentic", "workspace", "voice". ' +
5259
+ 'Archetype PRESET id, e.g. "drop-in-chat", "support-widget", "knowledge-base", "agentic", "workspace", "voice", "attachments". ' +
4379
5260
  'Shorthand for the preset\'s `components`. Omit it and pass `components` to compose a surface no preset names.',
4380
5261
  ),
4381
5262
  components: z
@@ -4383,7 +5264,7 @@ export const scaffold: Tool = {
4383
5264
  .optional()
4384
5265
  .describe(
4385
5266
  '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.',
5267
+ 'The real axis: any combination is renderable, not just the seven presets. Include "kai-chat". Wins over `useCase` when both are given.',
4387
5268
  ),
4388
5269
  integration: z
4389
5270
  .string()