unmagic-components 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (292) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -1
  3. data/README.md +506 -1
  4. data/app/assets/icons/lucide/LICENSE +43 -0
  5. data/app/assets/icons/lucide/arrow-down.svg +1 -0
  6. data/app/assets/icons/lucide/arrow-left.svg +1 -0
  7. data/app/assets/icons/lucide/arrow-up.svg +1 -0
  8. data/app/assets/icons/lucide/ban.svg +1 -0
  9. data/app/assets/icons/lucide/brain.svg +1 -0
  10. data/app/assets/icons/lucide/check.svg +1 -0
  11. data/app/assets/icons/lucide/chevron-down.svg +1 -0
  12. data/app/assets/icons/lucide/chevron-left.svg +1 -0
  13. data/app/assets/icons/lucide/chevron-right.svg +1 -0
  14. data/app/assets/icons/lucide/circle-check.svg +1 -0
  15. data/app/assets/icons/lucide/circle-dashed.svg +1 -0
  16. data/app/assets/icons/lucide/circle-question-mark.svg +1 -0
  17. data/app/assets/icons/lucide/circle-stop.svg +1 -0
  18. data/app/assets/icons/lucide/circle-x.svg +1 -0
  19. data/app/assets/icons/lucide/clock.svg +1 -0
  20. data/app/assets/icons/lucide/copy.svg +1 -0
  21. data/app/assets/icons/lucide/ellipsis-vertical.svg +1 -0
  22. data/app/assets/icons/lucide/file.svg +1 -0
  23. data/app/assets/icons/lucide/folder.svg +1 -0
  24. data/app/assets/icons/lucide/grip-vertical.svg +1 -0
  25. data/app/assets/icons/lucide/info.svg +1 -0
  26. data/app/assets/icons/lucide/lightbulb.svg +1 -0
  27. data/app/assets/icons/lucide/list-checks.svg +1 -0
  28. data/app/assets/icons/lucide/loader-circle.svg +1 -0
  29. data/app/assets/icons/lucide/messages-square.svg +1 -0
  30. data/app/assets/icons/lucide/paperclip.svg +1 -0
  31. data/app/assets/icons/lucide/pencil.svg +1 -0
  32. data/app/assets/icons/lucide/plus.svg +1 -0
  33. data/app/assets/icons/lucide/rotate-cw.svg +1 -0
  34. data/app/assets/icons/lucide/shield-alert.svg +1 -0
  35. data/app/assets/icons/lucide/timer.svg +1 -0
  36. data/app/assets/icons/lucide/triangle-alert.svg +1 -0
  37. data/app/assets/icons/lucide/wrench.svg +1 -0
  38. data/app/assets/icons/lucide/x.svg +1 -0
  39. data/app/assets/javascripts/unmagic/components/ai_chat.js +142 -0
  40. data/app/assets/javascripts/unmagic/components/autoscroll.js +103 -0
  41. data/app/assets/javascripts/unmagic/components/board.js +65 -0
  42. data/app/assets/javascripts/unmagic/components/dropzone.js +206 -0
  43. data/app/assets/javascripts/unmagic/components/elapsed.js +64 -0
  44. data/app/assets/javascripts/unmagic/components/optimistic.js +71 -0
  45. data/app/assets/javascripts/unmagic/components/placement.js +30 -0
  46. data/app/assets/javascripts/unmagic/components/slash_menu.js +186 -0
  47. data/app/assets/javascripts/unmagic/components/sortable.js +6 -0
  48. data/app/assets/javascripts/unmagic/components/sortable_item.js +122 -0
  49. data/app/assets/javascripts/unmagic/components/sortable_list.js +135 -0
  50. data/app/assets/javascripts/unmagic/components/sortable_param.js +14 -0
  51. data/app/assets/javascripts/unmagic/components/sortable_session.js +502 -0
  52. data/app/assets/javascripts/unmagic/components/streaming_markdown.js +269 -0
  53. data/app/assets/javascripts/unmagic/components/toasts.js +14 -4
  54. data/app/assets/javascripts/unmagic/components/toolbar.js +76 -0
  55. data/app/assets/javascripts/unmagic/components/upsert.js +4 -20
  56. data/app/assets/javascripts/unmagic/components.js +10 -0
  57. data/app/assets/tailwind/unmagic_components/engine.css +1515 -0
  58. data/lib/unmagic/components/action_view_helpers.rb +558 -3
  59. data/lib/unmagic/components/ai_chat/action_bar.rb +70 -0
  60. data/lib/unmagic/components/ai_chat/attachments.rb +119 -0
  61. data/lib/unmagic/components/ai_chat/branch_picker.rb +65 -0
  62. data/lib/unmagic/components/ai_chat/citation.rb +89 -0
  63. data/lib/unmagic/components/ai_chat/composer.rb +160 -0
  64. data/lib/unmagic/components/ai_chat/failure.rb +80 -0
  65. data/lib/unmagic/components/ai_chat/message.rb +142 -0
  66. data/lib/unmagic/components/ai_chat/payload.rb +93 -0
  67. data/lib/unmagic/components/ai_chat/permission.rb +143 -0
  68. data/lib/unmagic/components/ai_chat/plan.rb +91 -0
  69. data/lib/unmagic/components/ai_chat/proposal.rb +102 -0
  70. data/lib/unmagic/components/ai_chat/reasoning.rb +75 -0
  71. data/lib/unmagic/components/ai_chat/request.rb +206 -0
  72. data/lib/unmagic/components/ai_chat/section.rb +53 -0
  73. data/lib/unmagic/components/ai_chat/slash_menu.rb +68 -0
  74. data/lib/unmagic/components/ai_chat/tool_call.rb +181 -0
  75. data/lib/unmagic/components/ai_chat/transcript.rb +68 -0
  76. data/lib/unmagic/components/ai_chat/welcome.rb +73 -0
  77. data/lib/unmagic/components/ai_chat/workspace.rb +74 -0
  78. data/lib/unmagic/components/ai_chat.rb +31 -0
  79. data/lib/unmagic/components/avatar.rb +134 -0
  80. data/lib/unmagic/components/board.rb +182 -0
  81. data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/application_controller.rb +80 -0
  82. data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/demos_controller.rb +137 -0
  83. data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/pages_controller.rb +40 -0
  84. data/lib/unmagic/components/browser/app/helpers/unmagic/components/browser/application_helper.rb +52 -0
  85. data/lib/unmagic/components/browser/app/models/unmagic/components/browser/board_store.rb +88 -0
  86. data/lib/unmagic/components/browser/app/models/unmagic/components/browser/catalog.rb +65 -0
  87. data/lib/unmagic/components/browser/app/models/unmagic/components/browser/pager.rb +11 -0
  88. data/lib/unmagic/components/browser/app/models/unmagic/components/browser/profile.rb +25 -0
  89. data/lib/unmagic/components/browser/app/models/unmagic/components/browser/reply.rb +72 -0
  90. data/lib/unmagic/components/browser/app/models/unmagic/components/browser/thing.rb +31 -0
  91. data/lib/unmagic/components/browser/app/views/layouts/unmagic/components/browser/application.html.erb +65 -0
  92. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_code.html.erb +7 -0
  93. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_crumb.html.erb +7 -0
  94. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_example.html.erb +37 -0
  95. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_nav.html.erb +17 -0
  96. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/demos/edit_profile.html.erb +9 -0
  97. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_conversation.html.erb +101 -0
  98. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_live.html.erb +53 -0
  99. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_thumbnail.html.erb +7 -0
  100. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_welcome.html.erb +12 -0
  101. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_always.html.erb +6 -0
  102. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_hover.html.erb +12 -0
  103. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_thumbnail.html.erb +5 -0
  104. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_dropzone.html.erb +17 -0
  105. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_sent.html.erb +11 -0
  106. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_thumbnail.html.erb +6 -0
  107. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_branch_picker/_positions.html.erb +5 -0
  108. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_branch_picker/_thumbnail.html.erb +3 -0
  109. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_compact.html.erb +10 -0
  110. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_quote.html.erb +11 -0
  111. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_thumbnail.html.erb +7 -0
  112. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_parts.html.erb +14 -0
  113. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_states.html.erb +8 -0
  114. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_thumbnail.html.erb +5 -0
  115. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_full.html.erb +11 -0
  116. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_plain.html.erb +3 -0
  117. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_thumbnail.html.erb +5 -0
  118. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_parts.html.erb +23 -0
  119. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_states.html.erb +9 -0
  120. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_thumbnail.html.erb +4 -0
  121. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_turns.html.erb +16 -0
  122. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_json.html.erb +4 -0
  123. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_text.html.erb +4 -0
  124. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_thumbnail.html.erb +3 -0
  125. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_answered.html.erb +12 -0
  126. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_thumbnail.html.erb +7 -0
  127. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_waiting.html.erb +12 -0
  128. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_empty.html.erb +3 -0
  129. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_states.html.erb +9 -0
  130. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_thumbnail.html.erb +7 -0
  131. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_decided.html.erb +11 -0
  132. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_inline.html.erb +18 -0
  133. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_thumbnail.html.erb +6 -0
  134. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_settled.html.erb +6 -0
  135. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_thinking.html.erb +5 -0
  136. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_thumbnail.html.erb +3 -0
  137. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_answered.html.erb +10 -0
  138. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_choices.html.erb +13 -0
  139. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_form.html.erb +9 -0
  140. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_thumbnail.html.erb +8 -0
  141. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_slash_menu/_composer.html.erb +15 -0
  142. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_slash_menu/_thumbnail.html.erb +9 -0
  143. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_made.html.erb +12 -0
  144. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_run.html.erb +25 -0
  145. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_states.html.erb +22 -0
  146. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_thumbnail.html.erb +7 -0
  147. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_welcome/_suggestions.html.erb +11 -0
  148. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_welcome/_thumbnail.html.erb +6 -0
  149. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_workspace/_files.html.erb +12 -0
  150. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_workspace/_thumbnail.html.erb +7 -0
  151. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_group.html.erb +5 -0
  152. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_sizes.html.erb +8 -0
  153. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_thumbnail.html.erb +5 -0
  154. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_tints.html.erb +7 -0
  155. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_in_context.html.erb +14 -0
  156. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_thumbnail.html.erb +5 -0
  157. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_tones.html.erb +8 -0
  158. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_handles.html.erb +13 -0
  159. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_live.html.erb +34 -0
  160. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_thumbnail.html.erb +10 -0
  161. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_sizes.html.erb +12 -0
  162. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_thumbnail.html.erb +4 -0
  163. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_variants.html.erb +6 -0
  164. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_neutral.html.erb +3 -0
  165. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_thumbnail.html.erb +5 -0
  166. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_tones.html.erb +14 -0
  167. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_actions_and_footer.html.erb +7 -0
  168. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_flush_table.html.erb +6 -0
  169. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_link.html.erb +6 -0
  170. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_thumbnail.html.erb +6 -0
  171. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_danger.html.erb +3 -0
  172. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_plain.html.erb +2 -0
  173. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_thumbnail.html.erb +7 -0
  174. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_from_element.html.erb +4 -0
  175. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_thumbnail.html.erb +4 -0
  176. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_with_label.html.erb +3 -0
  177. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_inline.html.erb +5 -0
  178. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_stacked.html.erb +7 -0
  179. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_thumbnail.html.erb +5 -0
  180. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_dialog_tag.html.erb +8 -0
  181. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_modal.html.erb +10 -0
  182. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_thumbnail.html.erb +4 -0
  183. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_countdown.html.erb +3 -0
  184. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_running.html.erb +8 -0
  185. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_thumbnail.html.erb +4 -0
  186. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/empty_state/_default.html.erb +1 -0
  187. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/empty_state/_thumbnail.html.erb +3 -0
  188. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_autogrow.html.erb +4 -0
  189. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_control_classes.html.erb +54 -0
  190. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_fields.html.erb +28 -0
  191. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_states.html.erb +11 -0
  192. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_thumbnail.html.erb +8 -0
  193. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_uuid_field.html.erb +9 -0
  194. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_absolute.html.erb +5 -0
  195. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_relative.html.erb +14 -0
  196. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_thumbnail.html.erb +14 -0
  197. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_actions.html.erb +10 -0
  198. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_labelled.html.erb +6 -0
  199. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_thumbnail.html.erb +14 -0
  200. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_default.html.erb +7 -0
  201. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_thumbnail.html.erb +5 -0
  202. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_with_leading.html.erb +14 -0
  203. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_blocking_out.html.erb +25 -0
  204. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_card.html.erb +7 -0
  205. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_detail_list.html.erb +15 -0
  206. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_page_header.html.erb +7 -0
  207. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_text_and_button.html.erb +17 -0
  208. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_thumbnail.html.erb +10 -0
  209. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_connected.html.erb +17 -0
  210. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_grid.html.erb +20 -0
  211. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_handles.html.erb +22 -0
  212. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_thumbnail.html.erb +7 -0
  213. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_reveal.html.erb +19 -0
  214. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_stopped.html.erb +31 -0
  215. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_thumbnail.html.erb +5 -0
  216. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_deferred.html.erb +6 -0
  217. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_empty.html.erb +5 -0
  218. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_sortable.html.erb +8 -0
  219. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_table_tag.html.erb +4 -0
  220. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_thumbnail.html.erb +4 -0
  221. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_links.html.erb +5 -0
  222. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_panels.html.erb +18 -0
  223. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_thumbnail.html.erb +5 -0
  224. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_above_dialog.html.erb +10 -0
  225. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_flash.html.erb +13 -0
  226. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_stream.html.erb +6 -0
  227. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_thumbnail.html.erb +10 -0
  228. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_placement.html.erb +8 -0
  229. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_term.html.erb +5 -0
  230. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_thumbnail.html.erb +5 -0
  231. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/component.html.erb +58 -0
  232. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/installation.html.erb +47 -0
  233. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/overview.html.erb +49 -0
  234. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/theming.html.erb +52 -0
  235. data/lib/unmagic/components/browser/assets/browser.css +2 -0
  236. data/lib/unmagic/components/browser/catalog/ai_chat.rb +21 -0
  237. data/lib/unmagic/components/browser/catalog/ai_chat_action_bar.rb +15 -0
  238. data/lib/unmagic/components/browser/catalog/ai_chat_attachments.rb +15 -0
  239. data/lib/unmagic/components/browser/catalog/ai_chat_branch_picker.rb +12 -0
  240. data/lib/unmagic/components/browser/catalog/ai_chat_citation.rb +15 -0
  241. data/lib/unmagic/components/browser/catalog/ai_chat_composer.rb +16 -0
  242. data/lib/unmagic/components/browser/catalog/ai_chat_failure.rb +15 -0
  243. data/lib/unmagic/components/browser/catalog/ai_chat_message.rb +17 -0
  244. data/lib/unmagic/components/browser/catalog/ai_chat_payload.rb +16 -0
  245. data/lib/unmagic/components/browser/catalog/ai_chat_permission.rb +15 -0
  246. data/lib/unmagic/components/browser/catalog/ai_chat_plan.rb +14 -0
  247. data/lib/unmagic/components/browser/catalog/ai_chat_proposal.rb +15 -0
  248. data/lib/unmagic/components/browser/catalog/ai_chat_reasoning.rb +14 -0
  249. data/lib/unmagic/components/browser/catalog/ai_chat_request.rb +17 -0
  250. data/lib/unmagic/components/browser/catalog/ai_chat_slash_menu.rb +14 -0
  251. data/lib/unmagic/components/browser/catalog/ai_chat_tool_call.rb +17 -0
  252. data/lib/unmagic/components/browser/catalog/ai_chat_welcome.rb +13 -0
  253. data/lib/unmagic/components/browser/catalog/ai_chat_workspace.rb +12 -0
  254. data/lib/unmagic/components/browser/catalog/avatar.rb +15 -0
  255. data/lib/unmagic/components/browser/catalog/badge.rb +11 -0
  256. data/lib/unmagic/components/browser/catalog/board.rb +18 -0
  257. data/lib/unmagic/components/browser/catalog/button.rb +14 -0
  258. data/lib/unmagic/components/browser/catalog/callout.rb +13 -0
  259. data/lib/unmagic/components/browser/catalog/card.rb +16 -0
  260. data/lib/unmagic/components/browser/catalog/confirm.rb +14 -0
  261. data/lib/unmagic/components/browser/catalog/copy_button.rb +15 -0
  262. data/lib/unmagic/components/browser/catalog/detail_list.rb +14 -0
  263. data/lib/unmagic/components/browser/catalog/dialog.rb +15 -0
  264. data/lib/unmagic/components/browser/catalog/elapsed.rb +14 -0
  265. data/lib/unmagic/components/browser/catalog/empty_state.rb +11 -0
  266. data/lib/unmagic/components/browser/catalog/forms.rb +21 -0
  267. data/lib/unmagic/components/browser/catalog/local_time.rb +13 -0
  268. data/lib/unmagic/components/browser/catalog/menu.rb +15 -0
  269. data/lib/unmagic/components/browser/catalog/page_header.rb +13 -0
  270. data/lib/unmagic/components/browser/catalog/skeleton.rb +18 -0
  271. data/lib/unmagic/components/browser/catalog/sortable_list.rb +17 -0
  272. data/lib/unmagic/components/browser/catalog/streaming_markdown.rb +17 -0
  273. data/lib/unmagic/components/browser/catalog/table.rb +17 -0
  274. data/lib/unmagic/components/browser/catalog/tabs.rb +15 -0
  275. data/lib/unmagic/components/browser/catalog/toast.rb +17 -0
  276. data/lib/unmagic/components/browser/catalog/tooltip.rb +14 -0
  277. data/lib/unmagic/components/browser/config/routes.rb +41 -0
  278. data/lib/unmagic/components/browser/tailwind/browser.css +33 -0
  279. data/lib/unmagic/components/browser.rb +39 -0
  280. data/lib/unmagic/components/configuration.rb +31 -1
  281. data/lib/unmagic/components/duration.rb +35 -0
  282. data/lib/unmagic/components/elapsed.rb +46 -0
  283. data/lib/unmagic/components/engine.rb +9 -0
  284. data/lib/unmagic/components/icons.rb +34 -26
  285. data/lib/unmagic/components/modal.rb +1 -1
  286. data/lib/unmagic/components/page_header.rb +8 -0
  287. data/lib/unmagic/components/sortable.rb +104 -0
  288. data/lib/unmagic/components/streaming_markdown.rb +34 -0
  289. data/lib/unmagic/components/turbo_stream_actions.rb +16 -0
  290. data/lib/unmagic/components/version.rb +1 -1
  291. data/lib/unmagic/components.rb +30 -1
  292. metadata +308 -4
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module AIChat
6
+ # Files attached to a turn: the tiles a sent turn carries. The drop zone that
7
+ # takes them is Dropzone. See ActionViewHelpers#ai_chat_attachments.
8
+ class Attachments
9
+ ALIGNMENTS = %i[start end].freeze
10
+
11
+ File = Struct.new(:name, :size, :url, :thumbnail)
12
+
13
+ def initialize(view, align: :start, **options)
14
+ AIChat.validate!("ai_chat_attachments", :align, align, ALIGNMENTS)
15
+
16
+ @view = view
17
+ @align = align
18
+ @options = options
19
+ @files = []
20
+ end
21
+
22
+ def file(name, size: nil, url: nil, thumbnail: nil)
23
+ @files << File.new(name.to_s, size, url, thumbnail)
24
+ nil
25
+ end
26
+
27
+ def render
28
+ return "".html_safe if @files.empty?
29
+
30
+ view.content_tag(:ul, **@options,
31
+ class: view.class_names("UnmagicAIChatAttachments", "UnmagicAIChatAttachments--#{@align}", @options[:class])) do
32
+ safe_join(@files.map { |file| item(file) })
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :view
39
+
40
+ delegate :tag, :safe_join, to: :view, private: true
41
+
42
+ # A thumbnail is decoration: the name beside it is the name.
43
+ def item(file)
44
+ content = safe_join [
45
+ (file.thumbnail ? tag.img(src: file.thumbnail, alt: "", class: "UnmagicAIChatAttachments__thumb") : Icons.svg(view, :file, class: "UnmagicAIChatAttachments__glyph")),
46
+ tag.span(file.name, class: "UnmagicAIChatAttachments__name"),
47
+ (tag.span(view.number_to_human_size(file.size), class: "UnmagicAIChatAttachments__size") if file.size)
48
+ ].compact
49
+
50
+ tag.li(class: "UnmagicAIChatAttachments__file") do
51
+ file.url ? view.link_to(content, file.url, class: "UnmagicAIChatAttachments__link") : content
52
+ end
53
+ end
54
+ end
55
+
56
+ # A region that takes dropped and pasted files. See
57
+ # ActionViewHelpers#ai_chat_dropzone.
58
+ class Dropzone
59
+ def initialize(view, input:, url: nil, field: nil, chips: nil, label: nil, **options)
60
+ raise ArgumentError, "ai_chat_dropzone needs an input: (dragging is never the only way in)" if input.blank?
61
+ raise ArgumentError, "an uploading ai_chat_dropzone needs a field: to post the uploads under" if url && field.blank?
62
+
63
+ @view = view
64
+ @input = input
65
+ @url = url
66
+ @field = field
67
+ @chips = chips
68
+ @label = label
69
+ @options = options
70
+ end
71
+
72
+ # The overlay can't take the pointer, or it would swallow the drop it
73
+ # advertises; and it's only feedback for a gesture no keyboard can make.
74
+ def render(content)
75
+ label = @label || AIChat.t("attachments.drop", default: "Drop to attach")
76
+
77
+ view.content_tag("unmagic-dropzone", **@options,
78
+ input: @input, url: @url, field: @field, chips: @chips,
79
+ data: {
80
+ attached: AIChat.t("attachments.attached", name: "{name}", default: "%{name} attached"),
81
+ failed: AIChat.t("attachments.failed", name: "{name}", default: "Couldn't attach %{name}"),
82
+ remove: AIChat.t("attachments.remove", name: "{name}", default: "Remove %{name}")
83
+ }.merge(@options[:data] || {}),
84
+ class: view.class_names("UnmagicAIChatDropzone", @options[:class])) do
85
+ safe_join [
86
+ tag.div(tag.p(safe_join([ Icons.svg(view, :paperclip), label ])),
87
+ class: "UnmagicAIChatDropzone__overlay", "aria-hidden": "true"),
88
+ content,
89
+ tag.span(class: "UnmagicVisuallyHidden", "aria-live": "polite", "data-dropzone-status": ""),
90
+ chip
91
+ ].compact
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ attr_reader :view
98
+
99
+ delegate :tag, :safe_join, to: :view, private: true
100
+
101
+ # What an attached file looks like before it's sent. The element clones it,
102
+ # so the chip is the server's markup rather than HTML built in script.
103
+ def chip
104
+ tag.template("data-dropzone-chip": "") do
105
+ tag.div(class: "UnmagicAIChatAttachments__file") do
106
+ safe_join [
107
+ Icons.svg(view, :file, class: "UnmagicAIChatAttachments__glyph"),
108
+ tag.span(class: "UnmagicAIChatAttachments__name", "data-dropzone-name": ""),
109
+ tag.span(class: "UnmagicAIChatAttachments__size", "data-dropzone-size": ""),
110
+ tag.button(Icons.svg(view, :x), type: "button", class: "UnmagicAIChatAttachments__remove",
111
+ "data-dropzone-remove": "")
112
+ ]
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module AIChat
6
+ # Walking between alternative versions of a turn. It renders counts and
7
+ # follows links; what a branch is stays the host's business. See
8
+ # ActionViewHelpers#ai_chat_branch_picker.
9
+ class BranchPicker
10
+ def initialize(view, index:, count:, previous: nil, next: nil, method: :get, **options)
11
+ raise ArgumentError, "ai_chat_branch_picker count must be at least 1 (got #{count.inspect})" unless count.to_i >= 1
12
+ unless (1..count).cover?(index)
13
+ raise ArgumentError, "ai_chat_branch_picker index #{index.inspect} is outside 1..#{count}"
14
+ end
15
+
16
+ @view = view
17
+ @index = index
18
+ @count = count
19
+ @previous = previous
20
+ @next = binding.local_variable_get(:next)
21
+ @method = method
22
+ @options = options
23
+ end
24
+
25
+ # One version isn't a branch, and a 1/1 on every turn is noise on every turn.
26
+ def render
27
+ return "".html_safe if @count == 1
28
+
29
+ label = AIChat.t("branch_picker.label", index: @index, count: @count, default: "Version %{index} of %{count}")
30
+
31
+ view.content_tag(:div, **@options, role: "group", "aria-label": label,
32
+ class: view.class_names("UnmagicAIChatBranchPicker", @options[:class])) do
33
+ safe_join [
34
+ step(@previous, :chevron_left, AIChat.t("branch_picker.previous", default: "Previous version")),
35
+ tag.span("#{@index}/#{@count}", class: "UnmagicAIChatBranchPicker__count", "aria-hidden": "true"),
36
+ step(@next, :chevron_right, AIChat.t("branch_picker.next", default: "Next version"))
37
+ ]
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :view
44
+
45
+ delegate :tag, :safe_join, to: :view, private: true
46
+
47
+ # An end with nowhere to go is a span, not a disabled link: there's no such
48
+ # thing, and it would be a lie to the keyboard.
49
+ def step(url, icon, label)
50
+ classes = "UnmagicAIChatBranchPicker__step"
51
+ glyph = Icons.svg(view, icon)
52
+
53
+ if url.nil?
54
+ tag.span(glyph, "aria-disabled": "true", title: label, class: classes)
55
+ elsif @method == :get
56
+ view.link_to(glyph, url, "aria-label": label, title: label, class: classes)
57
+ else
58
+ view.button_to(url, method: @method, "aria-label": label, title: label, class: classes,
59
+ form: { class: "UnmagicAIChatBranchPicker__form" }) { glyph }
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module AIChat
6
+ # A quotation of a record the application holds, rendered from the record
7
+ # rather than from the model's paraphrase of it. See
8
+ # ActionViewHelpers#ai_chat_citation.
9
+ class Citation
10
+ def initialize(view, compact: false, **options)
11
+ @view = view
12
+ @compact = compact
13
+ @options = options
14
+ @avatar = nil
15
+ @who = nil
16
+ @when = nil
17
+ @quote = nil
18
+ end
19
+
20
+ # With no block, the gem's own avatar for whoever who: names, hidden from
21
+ # assistive technology since the name is read beside it.
22
+ def avatar(content = nil, src: nil, &block)
23
+ @avatar = block ? view.capture(&block) : (content || { src: src })
24
+ nil
25
+ end
26
+
27
+ def who(text)
28
+ @who = text
29
+ nil
30
+ end
31
+
32
+ def when(time, url: nil)
33
+ @when = [ time, url ]
34
+ nil
35
+ end
36
+
37
+ # Plain text, escaped, newlines kept. The quoted record can't inject markup
38
+ # through it, and neither can the model that chose to quote it.
39
+ def quote(text)
40
+ @quote = text.to_s
41
+ nil
42
+ end
43
+
44
+ def render
45
+ return "".html_safe if [ @who, @when, @quote ].all?(&:blank?)
46
+
47
+ view.content_tag(:figure, **@options,
48
+ class: view.class_names("UnmagicAIChatCitation", "not-prose",
49
+ { "UnmagicAIChatCitation--compact" => @compact }, @options[:class])) do
50
+ safe_join [
51
+ (tag.div(avatar_markup, class: "UnmagicAIChatCitation__avatar") if @avatar && !@compact),
52
+ tag.div(class: "UnmagicAIChatCitation__main") do
53
+ safe_join [ caption, (tag.blockquote(@quote, cite: @when&.last, class: "UnmagicAIChatCitation__quote") if @quote.present?) ].compact
54
+ end
55
+ ].compact
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ attr_reader :view
62
+
63
+ delegate :tag, :safe_join, to: :view, private: true
64
+
65
+ def avatar_markup
66
+ return @avatar unless @avatar.is_a?(Hash)
67
+
68
+ Avatar.new(view, @who, src: @avatar[:src], size: :small, "aria-hidden": "true").render
69
+ end
70
+
71
+ def caption
72
+ return if @who.blank? && @when.nil?
73
+
74
+ tag.figcaption(class: "UnmagicAIChatCitation__caption") do
75
+ safe_join [ (tag.span(@who, class: "UnmagicAIChatCitation__who") if @who.present?), timestamp ].compact
76
+ end
77
+ end
78
+
79
+ def timestamp
80
+ return unless @when
81
+
82
+ time, url = @when
83
+ stamp = view.local_time_tag(time)
84
+ url ? view.link_to(stamp, url, class: "UnmagicAIChatCitation__when") : tag.span(stamp, class: "UnmagicAIChatCitation__when")
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module AIChat
6
+ # The box a person types into, and the one button at the end of it. See
7
+ # ActionViewHelpers#ai_chat_composer.
8
+ class Composer
9
+ STATES = %i[idle running stopping waiting].freeze
10
+ STOP_FORM = "stop_turn"
11
+
12
+ class << self
13
+ # The action region on its own: Send, or Stop while a turn runs. Neither
14
+ # button is nested in the form it submits (both name it with form=), which
15
+ # is what lets a broadcast redraw this region without a form builder and
16
+ # without taking the half-typed draft with it.
17
+ def action(view, form:, state:, label: nil, stop_form: STOP_FORM)
18
+ AIChat.validate!("ai_chat_composer", :state, state, STATES)
19
+
20
+ view.tag.div(id: "#{form}_action", class: "UnmagicAIChatComposer__action", "aria-live": "polite") do
21
+ case state
22
+ when :idle then send_button(view, form, label)
23
+ when :running then stop_button(view, stop_form)
24
+ when :stopping then stopping_button(view)
25
+ when :waiting
26
+ view.safe_join [
27
+ view.tag.span(AIChat.t("composer.waiting", default: "Waiting for your input…"),
28
+ class: "UnmagicAIChatComposer__hint"),
29
+ stop_button(view, stop_form)
30
+ ]
31
+ end
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def send_button(view, form, label)
38
+ label ||= AIChat.t("composer.send", default: "Send")
39
+ view.tag.button(type: "submit", form: form, class: Button.classes(:primary)) do
40
+ view.safe_join [ view.tag.span(label), Icons.svg(view, :arrow_up) ]
41
+ end
42
+ end
43
+
44
+ def stop_button(view, stop_form)
45
+ view.tag.button(type: "submit", form: stop_form,
46
+ class: view.class_names(Button.classes, "UnmagicAIChatComposer__stop")) do
47
+ view.safe_join [ Icons.svg(view, :circle_stop), AIChat.t("composer.stop", default: "Stop") ]
48
+ end
49
+ end
50
+
51
+ # Asked, not yet granted: a button still reading Stop looks like one that
52
+ # didn't work, and gets pressed again.
53
+ def stopping_button(view)
54
+ view.tag.button(type: "button", disabled: true, class: Button.classes) do
55
+ view.safe_join [
56
+ Icons.svg(view, :loader_circle, class: "UnmagicAIChatSpinner"),
57
+ AIChat.t("composer.stopping", default: "Stopping…")
58
+ ]
59
+ end
60
+ end
61
+ end
62
+
63
+ def initialize(view, form:, field:, state: :idle, label: nil, stop_form: STOP_FORM, placeholder: nil,
64
+ rows: 2, **options)
65
+ form_id = form.options[:id]
66
+ raise ArgumentError, "ai_chat_composer needs its form to have an id: (the Send button names it)" if form_id.blank?
67
+
68
+ AIChat.validate!("ai_chat_composer", :state, state, STATES)
69
+
70
+ @view = view
71
+ @form = form
72
+ @form_id = form_id.to_s
73
+ @field = field
74
+ @state = state
75
+ @label = label
76
+ @stop_form = stop_form
77
+ @placeholder = placeholder
78
+ @rows = rows
79
+ @options = options
80
+ @optimistic = nil
81
+ @attach = nil
82
+ @menu = nil
83
+ @actions = nil
84
+ end
85
+
86
+ # Draw the question into the transcript the moment it's sent, under an id
87
+ # the form mints and the server's own row will come back with.
88
+ def optimistic(id:, container:)
89
+ @optimistic = { id: id, container: container }
90
+ nil
91
+ end
92
+
93
+ def attach(content = nil, &block)
94
+ @attach = block ? view.capture(&block) : content
95
+ nil
96
+ end
97
+
98
+ def menu(content = nil, &block)
99
+ @menu = block ? view.capture(&block) : content
100
+ nil
101
+ end
102
+
103
+ def actions(content = nil, &block)
104
+ @actions = block ? view.capture(&block) : content
105
+ nil
106
+ end
107
+
108
+ def render
109
+ box = tag.div(class: "UnmagicAIChatComposer__box") do
110
+ safe_join [
111
+ (view.uuid_input_tag(@optimistic[:id]) if @optimistic),
112
+ field,
113
+ (tag.div(@attach, class: "UnmagicAIChatComposer__attach") if @attach.present?),
114
+ (tag.div(@actions, class: "UnmagicAIChatComposer__actions") if @actions.present?),
115
+ self.class.action(view, form: @form_id, state: @state, label: @label, stop_form: @stop_form)
116
+ ].compact
117
+ end
118
+
119
+ view.content_tag(:div, **@options, class: view.class_names("UnmagicAIChatComposer", @options[:class])) do
120
+ safe_join [
121
+ @menu.presence,
122
+ tag.div(class: "UnmagicAIChatComposer__chips", "data-ai-chat-chips": ""),
123
+ (@optimistic ? optimistic_wrapper(box) : box)
124
+ ].compact
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ attr_reader :view
131
+
132
+ delegate :tag, :safe_join, to: :view, private: true
133
+
134
+ # The textarea is the composer's own chrome rather than a styled control:
135
+ # the box around it is the control and carries the focus ring, so it skips
136
+ # the control_class seam that would give it a border of its own. It is
137
+ # never disabled, whatever the state — only the button changes.
138
+ def field
139
+ label = @placeholder.presence || AIChat.t("composer.label", default: "Message")
140
+ value = @form.object.try(@field) if @form.object.respond_to?(@field)
141
+
142
+ Autogrow.wrap(view, view.text_area_tag(@form.field_name(@field), value,
143
+ id: @form.field_id(@field), rows: @rows, required: true, placeholder: @placeholder,
144
+ "aria-label": label, class: "UnmagicAIChatComposer__field", "data-ai-chat-composer-field": ""))
145
+ end
146
+
147
+ def optimistic_wrapper(box)
148
+ text = @form.field_name(@field)
149
+
150
+ view.content_tag("unmagic-optimistic", container: @optimistic[:container], class: "UnmagicOptimistic") do
151
+ safe_join [
152
+ box,
153
+ tag.template(Message.new(view, role: :user, optimistic: { id: @optimistic[:id], text: text }).render(""))
154
+ ]
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module AIChat
6
+ # A turn that fell over, and what it fell over on. See
7
+ # ActionViewHelpers#ai_chat_failure.
8
+ class Failure
9
+ def initialize(view, live: false, **options)
10
+ @view = view
11
+ @live = live
12
+ @options = options
13
+ @cause = nil
14
+ @details = []
15
+ @retry = nil
16
+ end
17
+
18
+ # One line naming the error, above the fold.
19
+ def cause(text)
20
+ @cause = text
21
+ nil
22
+ end
23
+
24
+ # A row inside the disclosure, for whoever is working on the assistant.
25
+ def detail(label, payload, language: nil)
26
+ @details << Payload.new(view, payload, label: label, language: language)
27
+ nil
28
+ end
29
+
30
+ def retry(content = nil, &block)
31
+ @retry = block ? view.capture(&block) : content
32
+ nil
33
+ end
34
+
35
+ # The sentence is always the caller's: the raw exception is for the logs,
36
+ # never for whoever asked. role="alert" only when streamed in, or every past
37
+ # failure in a reloaded transcript would be announced.
38
+ def render(message)
39
+ details = @details.map(&:render).select(&:present?)
40
+
41
+ view.content_tag(:div, **@options, role: ("alert" if @live),
42
+ class: view.class_names("UnmagicAIChatFailure", @options[:class])) do
43
+ safe_join [
44
+ tag.div(class: "UnmagicAIChatFailure__head") do
45
+ safe_join [
46
+ Icons.svg(view, :circle_x, class: "UnmagicAIChatFailure__icon"),
47
+ tag.div(class: "UnmagicAIChatFailure__text") do
48
+ safe_join [
49
+ tag.p(message, class: "UnmagicAIChatFailure__message"),
50
+ (tag.p(@cause, class: "UnmagicAIChatFailure__cause") if @cause.present?)
51
+ ].compact
52
+ end
53
+ ]
54
+ end,
55
+ (disclosure(details) if details.any?),
56
+ (tag.div(@retry, class: "UnmagicAIChatFailure__retry") if @retry.present?)
57
+ ].compact
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ attr_reader :view
64
+
65
+ delegate :tag, :safe_join, to: :view, private: true
66
+
67
+ def disclosure(details)
68
+ tag.details(class: "UnmagicAIChatFailure__details") do
69
+ safe_join [
70
+ tag.summary(class: "UnmagicAIChatFailure__summary") do
71
+ safe_join [ AIChat.t("failure.details", default: "Details"), Icons.svg(view, :chevron_right, class: "UnmagicAIChatChevron") ]
72
+ end,
73
+ tag.div(safe_join(details), class: "UnmagicAIChatFailure__body")
74
+ ]
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module AIChat
6
+ # One turn: a user's bubble of plain text, or an assistant's unbubbled
7
+ # prose. See ActionViewHelpers#ai_chat_message.
8
+ class Message
9
+ ROLES = %i[user assistant].freeze
10
+
11
+ def initialize(view, role:, id: nil, streaming: false, final: false, optimistic: nil, **options)
12
+ AIChat.validate!("ai_chat_message", :role, role, ROLES)
13
+ if optimistic && role != :user
14
+ raise ArgumentError, "only a user turn can be optimistic (the composer draws what was typed)"
15
+ end
16
+ if optimistic && !(optimistic.key?(:id) && optimistic.key?(:text))
17
+ raise ArgumentError, "optimistic: needs id: and text:, naming the form fields to fill them from"
18
+ end
19
+
20
+ @view = view
21
+ @role = role
22
+ @id = id
23
+ @streaming = streaming
24
+ @final = final
25
+ @optimistic = optimistic
26
+ @options = options
27
+ @reasoning = nil
28
+ @actions = nil
29
+ @branches = nil
30
+ @attachments = nil
31
+ end
32
+
33
+ # A collapsed "thought process" above the body. Takes ai_chat_reasoning's
34
+ # options; the body is the block.
35
+ def reasoning(content = nil, **options, &block)
36
+ @reasoning = Reasoning.new(view, id: ("#{@id}_reasoning" if @id), **options)
37
+ .render(block ? view.capture(&block) : content)
38
+ nil
39
+ end
40
+
41
+ def actions(content = nil, &block)
42
+ @actions = block ? view.capture(&block) : content
43
+ nil
44
+ end
45
+
46
+ def branches(content = nil, &block)
47
+ @branches = block ? view.capture(&block) : content
48
+ nil
49
+ end
50
+
51
+ # Above a user's bubble, which is the order it happened in.
52
+ def attachments(content = nil, &block)
53
+ @attachments = block ? view.capture(&block) : content
54
+ nil
55
+ end
56
+
57
+ def render(body)
58
+ @role == :user ? user(body) : assistant(body)
59
+ end
60
+
61
+ private
62
+
63
+ attr_reader :view
64
+
65
+ delegate :tag, :safe_join, to: :view, private: true
66
+
67
+ def user(body)
68
+ data = @optimistic ? { optimistic_id: @optimistic[:id], optimistic: "" } : {}
69
+
70
+ root(data: data) do
71
+ safe_join [
72
+ role_label,
73
+ (tag.div(@attachments, class: "UnmagicAIChatMessage__attachments") if @attachments.present?),
74
+ tag.div(@optimistic ? "" : trimmed(body), class: "UnmagicAIChatMessage__bubble",
75
+ data: (@optimistic ? { optimistic_text: @optimistic[:text] } : {})),
76
+ footer
77
+ ].compact
78
+ end
79
+ end
80
+
81
+ # A settled turn with nothing to say is hidden rather than dropped, so a
82
+ # broadcast can still key on its id. A turn still streaming is never empty:
83
+ # it shows the thinking spinner until its first token replaces it.
84
+ def assistant(body)
85
+ blank = body.blank? && @reasoning.blank? && !@streaming
86
+
87
+ root(hidden: blank) do
88
+ safe_join [ role_label, @reasoning.presence, content(body), footer ].compact
89
+ end
90
+ end
91
+
92
+ # The body is always the streaming element when the turn has an id, even once
93
+ # it has settled: the settled render upserts over the streaming one, and the
94
+ # element carries the reveal across that swap.
95
+ def content(body)
96
+ body = thinking if body.blank? && @streaming && !@final
97
+ return tag.div(body, class: "UnmagicAIChatMessage__body UnmagicProse") unless @id
98
+
99
+ StreamingMarkdown.new(view, id: "#{@id}_content", final: @final, streaming: @streaming,
100
+ class: "UnmagicAIChatMessage__body UnmagicProse").render(body)
101
+ end
102
+
103
+ # The bubble keeps the line breaks that were typed, so a block's own leading
104
+ # newline and indentation would show as a blank first line. Trimming the ends
105
+ # can't make markup, so already-escaped content stays safe.
106
+ def trimmed(body)
107
+ return body.to_s.strip unless body.respond_to?(:html_safe?) && body.html_safe?
108
+
109
+ body.to_str.strip.html_safe # rubocop:disable Rails/OutputSafety -- escaped content, only whitespace removed
110
+ end
111
+
112
+ def thinking
113
+ tag.span(class: "UnmagicAIChatMessage__thinking") do
114
+ safe_join [
115
+ Icons.svg(view, :loader_circle, class: "UnmagicAIChatSpinner"),
116
+ tag.span(AIChat.t("message.thinking", default: "Thinking"), class: "UnmagicVisuallyHidden")
117
+ ]
118
+ end
119
+ end
120
+
121
+ def footer
122
+ return if @actions.blank? && @branches.blank?
123
+
124
+ tag.div(safe_join([ @branches.presence, @actions.presence ].compact), class: "UnmagicAIChatMessage__footer")
125
+ end
126
+
127
+ def role_label
128
+ default = @role == :user ? "You said" : "Assistant said"
129
+ tag.span(AIChat.t("message.#{@role}", default: default), class: "UnmagicVisuallyHidden")
130
+ end
131
+
132
+ def root(hidden: false, data: {}, &block)
133
+ view.content_tag(:div, **@options,
134
+ id: (@id unless @optimistic),
135
+ hidden: hidden,
136
+ data: data.merge(@options[:data] || {}),
137
+ class: view.class_names("UnmagicAIChatMessage", "UnmagicAIChatMessage--#{@role}", @options[:class]), &block)
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end