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
@@ -354,9 +354,11 @@ module Unmagic
354
354
  # <%= modal_link_to "Edit", edit_label_path(@label), class: button_classes %>
355
355
  # <% end %>
356
356
  #
357
- # The builder also takes title and description blocks for markup, and leading
358
- # for something before the title, such as an avatar. Other options go on the
359
- # <header>.
357
+ # The builder also takes title and description blocks for markup, leading for
358
+ # something before the title, such as an avatar, and trailing for markup
359
+ # beside the title that is already rendered — a status partial, a row of
360
+ # badges a helper returns — which can't go through badge because that would
361
+ # wrap a badge in a badge. Other options go on the <header>.
360
362
  def page_header(title: nil, description: nil, back: nil, **options, &block)
361
363
  builder = Components::PageHeader.new(self, title: title, description: description, back: back, **options)
362
364
  builder.render(block ? capture(builder, &block) : nil)
@@ -519,6 +521,9 @@ module Unmagic
519
521
  # is on screen. Hovering or focusing a toast holds it open. The tone comes
520
522
  # from config.flash_tones (notice is :good, alert :bad).
521
523
  #
524
+ # duration: 0 keeps a toast up until it is dismissed — for a test that asserts
525
+ # on one without racing the timer, or a message that should not go by itself.
526
+ #
522
527
  # Pass the flashes to show when some aren't meant for the user:
523
528
  #
524
529
  # <%= flash_toasts flash.to_hash.except("copy_link") %>
@@ -537,6 +542,556 @@ module Unmagic
537
542
  Components::ConfirmTemplate.new(self).render
538
543
  end
539
544
 
545
+ # A clock counting up from a moment the server named, a second at a time — the
546
+ # difference between work that takes a minute and work that has hung.
547
+ #
548
+ # <%= elapsed_tag tool_call.started_at %> "3m 5s"
549
+ # <%= elapsed_tag session.expires_at, direction: :down %> "42s"
550
+ #
551
+ # direction: :up (default) counts from the time, :down counts to it and stops
552
+ # at zero, firing unmagic-elapsed:end. The server renders the current reading,
553
+ # so it is right before the script loads. The same words as a settled
554
+ # duration: 640ms, 2s, 3m 5s, 1h 4m 2s (the unmagic.components.elapsed.* keys).
555
+ # A blank time renders an em dash. Other options go on the element. Needs
556
+ # import "unmagic/components/elapsed".
557
+ def elapsed_tag(time, direction: :up, **options)
558
+ return "—" if time.blank?
559
+
560
+ Components::Elapsed.new(self, time, direction: direction, **options).render
561
+ end
562
+
563
+ # Server-rendered HTML revealed at a steady pace as fuller renders of it are
564
+ # streamed in — a model's reply, reading as one smooth stream rather than the
565
+ # bursts it arrives in.
566
+ #
567
+ # <%= streaming_markdown_tag id: "message_1_content", streaming: true do %>
568
+ # <%= Markdown.render(message.content) %>
569
+ # <% end %>
570
+ #
571
+ # Send each flush with turbo_stream.stream_markdown, carrying the whole reply
572
+ # so far. The gem never parses Markdown: the content is the host's own
573
+ # rendered, sanitised HTML. final: true settles it, refusing any later flush
574
+ # (a stopped or failed reply). streaming: true marks it aria-busy until it
575
+ # catches up, so a live region announces it once. Reduced motion paints each
576
+ # flush in full. Other options go on the element. Needs import
577
+ # "unmagic/components/streaming_markdown".
578
+ def streaming_markdown_tag(content = nil, id:, final: false, streaming: false, **options, &block)
579
+ Components::StreamingMarkdown.new(self, id: id, final: final, streaming: streaming, **options)
580
+ .render(block ? capture(&block) : content)
581
+ end
582
+
583
+ # A person's or organisation's picture, falling back to their initials on one
584
+ # of six tints picked from the name, the same on every page and server.
585
+ #
586
+ # <%= avatar "Ada Lovelace" %>
587
+ # <%= avatar @user.name, src: @user.avatar_url, size: :large %>
588
+ # <%= avatar "Acme Ltd", shape: :square %>
589
+ #
590
+ # size: :small, :medium (default) or :large. shape: :circle (default) or
591
+ # :square. tint: false for the neutral surface. skeleton: true renders a
592
+ # skeleton circle of the same size. A blank src falls back to the initials,
593
+ # and a broken image shows them through it. The avatar is role="img" named
594
+ # for the person; pass "aria-hidden": true when the name is already written
595
+ # beside it. Other options go on the <span>.
596
+ def avatar(name, src: nil, size: :medium, shape: :circle, tint: true, skeleton: false, **options)
597
+ Components::Avatar.new(self, name, src: src, size: size, shape: shape, tint: tint, skeleton: skeleton, **options).render
598
+ end
599
+
600
+ # A stack of avatars.
601
+ #
602
+ # <%= avatar_group max: 3, size: :small do |group| %>
603
+ # <% @members.each { |member| group.avatar member.name, src: member.avatar_url } %>
604
+ # <% end %>
605
+ #
606
+ # Past max: the rest collapse into a "+N" whose title lists them. The group
607
+ # sets size: and shape: for every avatar in it. Other options go on the <div>.
608
+ def avatar_group(max: nil, size: :medium, shape: :circle, **options, &block)
609
+ builder = Components::AvatarGroup.new(self, max: max, size: size, shape: shape, **options)
610
+ capture(builder, &block)
611
+ builder.render
612
+ end
613
+
614
+ # A list whose items can be dragged, or moved with the keyboard, into a new
615
+ # place — here, or in another list sharing its namespace.
616
+ #
617
+ # <%= sortable_list namespace: "cards", params: { column_id: column.id } do |list| %>
618
+ # <% column.cards.ordered.each do |card| %>
619
+ # <%= list.item(card) { render card } %>
620
+ # <% end %>
621
+ # <% end %>
622
+ #
623
+ # list.item takes a record, whose key and rank come from
624
+ # config.sortable_item (unmagic-sortable sets it to signed keys), or key: and
625
+ # rank: directly; label: is what a screen reader calls it. Put a
626
+ # sortable_handle in an item to drag it only by that, which keeps the rest of
627
+ # it clickable; without one the whole item drags once the pointer moves, and
628
+ # the item is its own keyboard stop.
629
+ #
630
+ # namespace: lets items move between lists that share it. params: are posted
631
+ # with a drop into this list (the destination column). orientation:
632
+ # :vertical (default), :horizontal or :grid decides which arrow keys move an
633
+ # item. label: names the list in announcements.
634
+ #
635
+ # A drop fires a cancelable unmagic-sortable:move, then PATCHes url:
636
+ # (config.sortable_url by default) with moved, original, prev, next and the
637
+ # params. Keyboard: Space or Enter picks an item up, the arrows move it (the
638
+ # other arrows move it between lists), Space drops it, Escape puts it back.
639
+ # Escape cancels a pointer drag too. Other options go on the element. Needs
640
+ # import "unmagic/components/sortable".
641
+ def sortable_list(namespace: nil, params: {}, url: nil, orientation: :vertical, label: nil, **options, &block)
642
+ builder = Components::Sortable::List.new(self, namespace: namespace, params: params, url: url,
643
+ orientation: orientation, label: label, **options)
644
+ capture(builder, &block) if block
645
+ builder.render
646
+ end
647
+
648
+ # The grip a sortable item is dragged by, and its keyboard stop.
649
+ #
650
+ # <%= sortable_handle %>
651
+ # <%= sortable_handle label: "Move #{step.name}" %>
652
+ #
653
+ # label: defaults to "Drag to reorder". Other options go on the <button>.
654
+ def sortable_handle(label: nil, **options)
655
+ Components::Sortable.handle(self, label: label, **options)
656
+ end
657
+
658
+ # A Trello-style board of reorderable columns of reorderable cards.
659
+ #
660
+ # <%= board id: "roadmap" do |board| %>
661
+ # <% @columns.each do |column| %>
662
+ # <% board.column column, title: column.name, params: { column_id: column.id } do |col| %>
663
+ # <% col.actions { menu { |m| m.link "Rename", edit_column_path(column) } } %>
664
+ # <% column.cards.ordered.each do |card| %>
665
+ # <% col.card(card) { render card } %>
666
+ # <% end %>
667
+ # <% col.add url: cards_path, field: "card[title]", params: { "card[column_id]" => column.id } %>
668
+ # <% end %>
669
+ # <% end %>
670
+ # <% board.add_column url: columns_path, field: "column[name]" %>
671
+ # <% end %>
672
+ #
673
+ # id: names the board and its lists: cards move between any of its columns,
674
+ # posting the column's params:, and columns move along the board. url: is where
675
+ # drops post (config.sortable_url by default); columns_url: overrides it for
676
+ # columns. sortable_columns: false fixes the columns in place. column_height:
677
+ # is how tall a column grows before its cards scroll (75dvh), through the
678
+ # --unmagic-board-column-height knob.
679
+ #
680
+ # A column's header is its drag handle, with a grip for the keyboard; its
681
+ # actions stay clickable. board.column takes a record or key:/rank:, and
682
+ # count: when it shows fewer cards than it has. col.card takes what
683
+ # sortable_list's item does. col.add and board.add_column open into a
684
+ # one-field form that submits on Enter, closes on Escape, and stays open to add
685
+ # another. Other options go on the root <div>. Needs import
686
+ # "unmagic/components/sortable" and "unmagic/components/board".
687
+ def board(id:, url: nil, columns_url: nil, sortable_columns: true, label: nil, column_height: nil, **options, &block)
688
+ builder = Components::Board.new(self, id: id, url: url, columns_url: columns_url,
689
+ sortable_columns: sortable_columns, label: label, column_height: column_height, **options)
690
+ capture(builder, &block) if block
691
+ builder.render
692
+ end
693
+
694
+ # The scrolling region an AI chat's turns are rendered into — the root of the
695
+ # ai_chat_* components.
696
+ #
697
+ # <%= ai_chat id: "entries" do |chat| %>
698
+ # <% chat.welcome do %><%= ai_chat_welcome heading: "What can I help with?" %><% end %>
699
+ # <%= render @chat.timeline %>
700
+ # <% end %>
701
+ #
702
+ # It renders no entries of its own: render yours with a partial per entry
703
+ # type. id: is required, since it is what turns are upserted into. The log is
704
+ # role="log", announced politely. It follows new content while the reader is
705
+ # at the bottom and stops when they scroll up (follow: false to never follow),
706
+ # showing a jump-to-latest button meanwhile (scroll_to_latest: false for none).
707
+ # scroller: is a selector for the scrolling box when it isn't the page.
708
+ # chat.welcome shows while the conversation is empty and hides as the first
709
+ # entry arrives. Other options go on the log. Needs import
710
+ # "unmagic/components/autoscroll" and Turbo.
711
+ def ai_chat(id:, scroller: nil, follow: true, scroll_to_latest: true, **options, &block)
712
+ builder = Components::AIChat::Transcript.new(self, id: id, scroller: scroller, follow: follow,
713
+ scroll_to_latest: scroll_to_latest, **options)
714
+ entries = block ? capture(builder, &block) : nil
715
+ builder.render(entries)
716
+ end
717
+
718
+ # One turn: a user's bubble of plain text, or an assistant's unbubbled prose.
719
+ #
720
+ # <%= ai_chat_message role: :user, id: dom_id(message) do %><%= message.content %><% end %>
721
+ #
722
+ # <%= ai_chat_message role: :assistant, id: dom_id(message), streaming: message.pending? do |turn| %>
723
+ # <% turn.reasoning(duration: message.thinking_seconds) { Markdown.render(message.thinking) } %>
724
+ # <% turn.actions do %><%= ai_chat_action_bar for: dom_id(message) do |bar| %>…<% end %><% end %>
725
+ # <%= Markdown.render(message.content) %>
726
+ # <% end %>
727
+ #
728
+ # role: :user or :assistant. An assistant body is the host's rendered,
729
+ # sanitised HTML, inside an <unmagic-streaming-markdown> whose id is
730
+ # "#{id}_content" — the target for turbo_stream.stream_markdown. streaming:
731
+ # true shows a thinking spinner until the first token and marks the body
732
+ # busy; final: true refuses any later flush (a stopped or failed reply). A
733
+ # settled assistant turn with nothing in it renders hidden, keeping its id.
734
+ #
735
+ # Parts: reasoning (ai_chat_reasoning's options), actions, branches, and
736
+ # attachments (above a user's bubble). optimistic: { id:, text: } names the
737
+ # form fields a composer's template fills, and dims the bubble until the
738
+ # server's own turn replaces it. Other options go on the root <div>.
739
+ def ai_chat_message(content = nil, role:, id: nil, streaming: false, final: false, optimistic: nil, **options, &block)
740
+ builder = Components::AIChat::Message.new(self, role: role, id: id, streaming: streaming, final: final,
741
+ optimistic: optimistic, **options)
742
+ body = block ? capture(builder, &block) : content
743
+ builder.render(body)
744
+ end
745
+
746
+ # The model's thinking, collapsed above its reply.
747
+ #
748
+ # <%= ai_chat_reasoning duration: 12.4 do %><%= Markdown.render(thinking) %><% end %>
749
+ #
750
+ # Shut by default (open: true to open it). streaming: true shows "Thinking…"
751
+ # with a spinner; duration: (seconds) titles it "Thought for 12s"; title:
752
+ # replaces "Thought process". reasoning.block records one more block. Blank
753
+ # content renders nothing. Other options go on the <details>.
754
+ def ai_chat_reasoning(content = nil, title: nil, streaming: false, duration: nil, open: false, **options, &block)
755
+ builder = Components::AIChat::Reasoning.new(self, title: title, streaming: streaming, duration: duration,
756
+ open: open, **options)
757
+ body = block ? capture(builder, &block) : content
758
+ builder.render(body)
759
+ end
760
+
761
+ # One tool the agent reached for, as a row in a timeline.
762
+ #
763
+ # <%= ai_chat_tool_call name: call.name, state: call.state, id: dom_id(call) do |tool| %>
764
+ # <% tool.summary call.summary %>
765
+ # <% tool.timing started_at: call.started_at, duration: call.duration %>
766
+ # <% tool.asked call.arguments %>
767
+ # <% tool.answered call.response %>
768
+ # <% end %>
769
+ #
770
+ # state: :queued, :running, :waiting, :done or :failed. A done call shows
771
+ # icon: (what the call was about) in place of a tick. Consecutive rows are
772
+ # joined by a line; timeline: false leaves a row out of the run. Mark the
773
+ # hidden entries between two rows (a tool result's anchor) with
774
+ # data-ai-chat-timeline="gap" so the line runs over them.
775
+ #
776
+ # Parts: summary, timing (a live clock while running), asked and answered
777
+ # (ai_chat_payload), failures (a count for a call that mostly worked),
778
+ # progress (a line beside the spinner, with id "#{id}_progress" to replace),
779
+ # and made (content kept outside the fold). With no payloads there is no
780
+ # disclosure. open: true opens it. Other options go on the root <div>.
781
+ def ai_chat_tool_call(name:, state:, id: nil, icon: nil, open: false, timeline: true, **options, &block)
782
+ builder = Components::AIChat::ToolCall.new(self, name: name, state: state, id: id, icon: icon, open: open,
783
+ timeline: timeline, **options)
784
+ capture(builder, &block) if block
785
+ builder.render
786
+ end
787
+
788
+ # A tool's payload, or anything else to read exactly as written.
789
+ #
790
+ # <%= ai_chat_payload call.response, label: "Answered", duration: call.duration %>
791
+ #
792
+ # A Hash, an Array, or a string holding JSON is laid out a key to a line as
793
+ # :json; anything else is :plaintext. language: overrides that. The code block
794
+ # renders through config.code_block, so a host's highlighter colours it. It
795
+ # scrolls past a height and wraps long lines, and is reachable by keyboard.
796
+ # copy: true adds a copy button. A nil payload renders nothing. Other options
797
+ # go on the root <div>.
798
+ def ai_chat_payload(payload, label: nil, language: nil, duration: nil, copy: false, **options)
799
+ Components::AIChat::Payload.new(self, payload, label: label, language: language, duration: duration,
800
+ copy: copy, **options).render
801
+ end
802
+
803
+ # A turn that fell over.
804
+ #
805
+ # <%= ai_chat_failure "The assistant couldn't finish this reply." do |failure| %>
806
+ # <% failure.cause "Faraday::TimeoutError: execution expired" %>
807
+ # <% failure.detail "Where it happened", error.backtrace.join("\n"), language: :plaintext %>
808
+ # <% failure.retry { button_to "Try again", retry_path(chat) } %>
809
+ # <% end %>
810
+ #
811
+ # The sentence is for whoever asked; the details, folded away, are for
812
+ # whoever works on the assistant. Render it below a turn's body, never in
813
+ # place of it. live: true adds role="alert", for one streamed in. Other
814
+ # options go on the root <div>.
815
+ def ai_chat_failure(message = nil, live: false, **options, &block)
816
+ builder = Components::AIChat::Failure.new(self, live: live, **options)
817
+ content = block ? capture(builder, &block) : nil
818
+ builder.render(message || content)
819
+ end
820
+
821
+ # The checklist an agent is working through.
822
+ #
823
+ # <%= ai_chat_plan id: "plan" do |plan| %>
824
+ # <% @plan.steps.each { |step| plan.step step.title, state: step.status } %>
825
+ # <% end %>
826
+ #
827
+ # A step's state: is :pending, :in_progress, :waiting or :completed; a block
828
+ # adds detail under it. The count reads completed/total, from the steps
829
+ # unless completed:/total: are given. With no steps it shows empty: rather
830
+ # than nothing, since it is a broadcast target. open: false starts it shut;
831
+ # collapsible: false renders a plain section. title_tag: sets the heading
832
+ # level (:h2). Other options go on the root.
833
+ def ai_chat_plan(title: nil, completed: nil, total: nil, open: true, empty: nil, collapsible: true,
834
+ title_tag: :h2, **options, &block)
835
+ builder = Components::AIChat::Plan.new(self, title: title, completed: completed, total: total, open: open,
836
+ empty: empty, collapsible: collapsible, title_tag: title_tag, **options)
837
+ capture(builder, &block) if block
838
+ builder.render
839
+ end
840
+
841
+ # The files an agent has put aside while it works.
842
+ #
843
+ # <%= ai_chat_workspace id: "workspace" do |workspace| %>
844
+ # <% @files.each { |file| workspace.file file.path, size: file.byte_size } %>
845
+ # <% end %>
846
+ #
847
+ # A flat list of paths; url: makes a row a link and icon: changes its glyph
848
+ # (:file). Takes ai_chat_plan's title:, count:, open:, empty:, collapsible:
849
+ # and title_tag:. Other options go on the root.
850
+ def ai_chat_workspace(title: nil, count: nil, open: true, empty: nil, collapsible: true, title_tag: :h2,
851
+ **options, &block)
852
+ builder = Components::AIChat::Workspace.new(self, title: title, count: count, open: open, empty: empty,
853
+ collapsible: collapsible, title_tag: title_tag, **options)
854
+ capture(builder, &block) if block
855
+ builder.render
856
+ end
857
+
858
+ # The box a person types into, with Send — or Stop while a turn runs.
859
+ #
860
+ # <%= form_with model: Message.new, url: chat_messages_path(@chat), id: "composer" do |form| %>
861
+ # <%= ai_chat_composer form: form, field: :content, state: @chat.run_state do |composer| %>
862
+ # <% composer.optimistic id: "message[client_id]", container: "#entries" %>
863
+ # <% end %>
864
+ # <% end %>
865
+ # <%= ai_chat_stop_form chat_stop_path(@chat) %>
866
+ #
867
+ # The form needs an id:. state: :idle, :running, :stopping or :waiting
868
+ # changes only the button, never the field, so a draft survives it; the
869
+ # button's region has id "#{form_id}_action", to redraw with
870
+ # ai_chat_composer_action. Enter sends and Shift+Enter makes a new line, and
871
+ # Enter does nothing while there is no Send. The form resets after a
872
+ # successful submit. label: renames Send; placeholder: and rows: go to the
873
+ # field; stop_form: is the id Stop submits ("stop_turn").
874
+ #
875
+ # Parts: optimistic (draws the question into the transcript as it's sent,
876
+ # under an id <unmagic-uuid-input> mints), attach and actions (controls in
877
+ # the box), and menu (an ai_chat_slash_menu). Other options go on the root
878
+ # <div>. Needs import "unmagic/components/ai_chat" and Turbo.
879
+ def ai_chat_composer(form:, field:, state: :idle, label: nil, stop_form: Components::AIChat::Composer::STOP_FORM,
880
+ placeholder: nil, rows: 2, **options, &block)
881
+ builder = Components::AIChat::Composer.new(self, form: form, field: field, state: state, label: label,
882
+ stop_form: stop_form, placeholder: placeholder, rows: rows, **options)
883
+ capture(builder, &block) if block
884
+ builder.render
885
+ end
886
+
887
+ # The composer's button region on its own, for a broadcast that redraws it
888
+ # as the turn's state changes.
889
+ #
890
+ # <%= ai_chat_composer_action form: "composer", state: :running %>
891
+ def ai_chat_composer_action(form:, state:, label: nil, stop_form: Components::AIChat::Composer::STOP_FORM)
892
+ Components::AIChat::Composer.action(self, form: form, state: state, label: label, stop_form: stop_form)
893
+ end
894
+
895
+ # The empty form Stop submits. Render it outside the composer's form — a form
896
+ # can't sit inside another — and Stop reaches it by id.
897
+ #
898
+ # <%= ai_chat_stop_form chat_stop_path(@chat) %>
899
+ def ai_chat_stop_form(url, id: Components::AIChat::Composer::STOP_FORM, method: :post)
900
+ form_with(url: url, method: method, id: id, class: "UnmagicAIChatStopForm") { "" }
901
+ end
902
+
903
+ # The agent asking something it can't work out on its own.
904
+ #
905
+ # <%= ai_chat_request state: :waiting, url: answer_path(@chat), live: true do |request| %>
906
+ # <% request.question "Which chats should it read?", header: "Scope" do |q| %>
907
+ # <% q.option "This chat", description: "Only the one it asked about" %>
908
+ # <% q.option "Any chat", description: "Every conversation, from now on" %>
909
+ # <% end %>
910
+ # <% request.aside "Or say something else in the box below." %>
911
+ # <% end %>
912
+ #
913
+ # Ask with questions (multiple: true for checkboxes), or with request.form
914
+ # { |form| … } for fields of your own — not both. state: :waiting renders
915
+ # the form, posting answers[i][] (url:, method: :patch, scope: :response for
916
+ # a form); :accepted, :declined, :cancelled or :timed_out keeps the
917
+ # questions and shows what was picked (question picked:) or answered
918
+ # (request.answer). request.decline adds a Decline that skips validation.
919
+ # prompt: is the sentence above; label: renames Answer. live: true adds
920
+ # role="alert" for one streamed in, and takes focus if nothing has it. Other
921
+ # options go on the root <div>.
922
+ def ai_chat_request(state:, url: nil, method: :patch, prompt: nil, label: nil, scope: :response, live: false,
923
+ **options, &block)
924
+ builder = Components::AIChat::Request.new(self, state: state, url: url, method: method, prompt: prompt,
925
+ label: label, scope: scope, live: live, **options)
926
+ capture(builder, &block) if block
927
+ builder.render
928
+ end
929
+
930
+ # An agent asking to be allowed something, with its reasoning.
931
+ #
932
+ # <%= ai_chat_permission tool: "delete_resource", state: :waiting, url: permission_path(@call) do |ask| %>
933
+ # <% ask.argument "chat_id", 42 %>
934
+ # <% ask.summary { markdown(@call.summary) } %>
935
+ # <% ask.allow confirm: "Let this chat delete files? This can't be undone." %>
936
+ # <% ask.allow "Allow for any chat", params: { widened: true } %>
937
+ # <% ask.refuse %>
938
+ # <% end %>
939
+ #
940
+ # state: :waiting, :granted, :refused or :lapsed (answered in the chat, with
941
+ # nothing granted). The tool name is shown as given. The first allow is the
942
+ # prominent one; offer a second, wider allow only when there is something to
943
+ # widen. allow posts and refuse deletes to url:, each with its own params:
944
+ # and confirm:. Focus goes to the card, never to Allow. outcome: replaces an
945
+ # answered card's sentence; live: true adds role="alert". Other options go on
946
+ # the root <div>.
947
+ def ai_chat_permission(tool:, state:, url: nil, live: false, outcome: nil, **options, &block)
948
+ builder = Components::AIChat::Permission.new(self, tool: tool, state: state, url: url, live: live,
949
+ outcome: outcome, **options)
950
+ capture(builder, &block) if block
951
+ builder.render
952
+ end
953
+
954
+ # An offer the agent made in passing, which the reader can take up or not.
955
+ #
956
+ # <%= ai_chat_proposal state: fact.decision, id: dom_id(fact) do |offer| %>
957
+ # <% offer.claim "Prefers to be called Ana" %>
958
+ # <% offer.meta "About Ana Silva · 80% sure" %>
959
+ # <% offer.accept { button_to "Save", approve_path(fact), class: button_classes(size: :small) } %>
960
+ # <% offer.reject { button_to "Dismiss", reject_path(fact), class: button_classes(size: :small) } %>
961
+ # <% end %>
962
+ #
963
+ # state: :pending (default), :accepted or :rejected. The actions show while
964
+ # pending; a decided card shows outcome: ("Saved", "Dismissed") and a
965
+ # rejected one dims. icon: is its glyph (:lightbulb). It is marked not-prose,
966
+ # to sit inside a reply. Other options go on the <aside>.
967
+ def ai_chat_proposal(state: :pending, icon: :lightbulb, id: nil, **options, &block)
968
+ builder = Components::AIChat::Proposal.new(self, state: state, icon: icon, id: id, **options)
969
+ capture(builder, &block) if block
970
+ builder.render
971
+ end
972
+
973
+ # A quotation of a record, rendered from the record.
974
+ #
975
+ # <%= ai_chat_citation do |cite| %>
976
+ # <% cite.avatar src: message.sender.avatar_url %>
977
+ # <% cite.who message.sender.name %>
978
+ # <% cite.when message.sent_at, url: message_path(message) %>
979
+ # <% cite.quote message.body %>
980
+ # <% end %>
981
+ #
982
+ # The quote is escaped plain text with its line breaks kept. cite.avatar with
983
+ # no block draws the gem's avatar for who:. compact: true is one line, for a
984
+ # list of sources. Nothing given renders nothing. Other options go on the
985
+ # <figure>.
986
+ def ai_chat_citation(compact: false, **options, &block)
987
+ builder = Components::AIChat::Citation.new(self, compact: compact, **options)
988
+ capture(builder, &block) if block
989
+ builder.render
990
+ end
991
+
992
+ # What an empty conversation says, and suggestions that start one.
993
+ #
994
+ # <%= ai_chat_welcome heading: "What can I help with?" do |welcome| %>
995
+ # <% welcome.suggestion "Who hasn't replied yet?" %>
996
+ # <% welcome.suggestion "Invite someone to…", fill: true %>
997
+ # <% end %>
998
+ #
999
+ # Pressing a suggestion puts its text (value:, or the label) in the composer
1000
+ # form with id composer: ("composer") and sends it, unless fill: true or a
1001
+ # turn is running. field: names the field when the form has several.
1002
+ # heading_tag: sets the heading level. Other options go on the root <div>.
1003
+ # Needs import "unmagic/components/ai_chat".
1004
+ def ai_chat_welcome(heading:, heading_tag: :h2, composer: "composer", field: nil, **options, &block)
1005
+ builder = Components::AIChat::Welcome.new(self, heading: heading, heading_tag: heading_tag,
1006
+ composer: composer, field: field, **options)
1007
+ capture(builder, &block) if block
1008
+ builder.render
1009
+ end
1010
+
1011
+ # The files a sent turn carries.
1012
+ #
1013
+ # <%= ai_chat_attachments align: :end do |files| %>
1014
+ # <% message.files.each { |blob| files.file blob.filename, size: blob.byte_size, url: url_for(blob) } %>
1015
+ # <% end %>
1016
+ #
1017
+ # align: :end for a user's turn. thumbnail: is an image URL; without one the
1018
+ # tile shows a file glyph. None renders nothing. Other options go on the <ul>.
1019
+ def ai_chat_attachments(align: :start, **options, &block)
1020
+ builder = Components::AIChat::Attachments.new(self, align: align, **options)
1021
+ capture(builder, &block) if block
1022
+ builder.render
1023
+ end
1024
+
1025
+ # A region that takes dropped and pasted files for a composer.
1026
+ #
1027
+ # <%= ai_chat_dropzone input: "#composer_files", chips: "#composer [data-ai-chat-chips]" do %>
1028
+ # …the page…
1029
+ # <% end %>
1030
+ #
1031
+ # input: is the file input the files land in, which is also the way in for
1032
+ # anyone who can't drag. With url: each file is uploaded on the spot
1033
+ # (POST, as file) and the JSON response's value is posted with the message
1034
+ # under field:; without it the files wait on the input. chips: is where the
1035
+ # attached files are listed. label: is the overlay's words. It fires
1036
+ # unmagic-dropzone:attach and :error. Other options go on the element. Needs
1037
+ # import "unmagic/components/dropzone".
1038
+ def ai_chat_dropzone(input:, url: nil, field: nil, chips: nil, label: nil, **options, &block)
1039
+ Components::AIChat::Dropzone.new(self, input: input, url: url, field: field, chips: chips, label: label,
1040
+ **options).render(block ? capture(&block) : nil)
1041
+ end
1042
+
1043
+ # Commands offered on a slash in a composer.
1044
+ #
1045
+ # <% composer.menu do %>
1046
+ # <%= ai_chat_slash_menu above: true do |menu| %>
1047
+ # <% @skills.each { |skill| menu.item skill.name, description: skill.description } %>
1048
+ # <% end %>
1049
+ # <% end %>
1050
+ #
1051
+ # Every item is rendered and the typing filters them. Arrows move, Enter or
1052
+ # Tab takes one, Escape closes. Picking writes "/name " at the cursor.
1053
+ # arguments: lists hints after the name. for: is the field's id (the
1054
+ # composer's by default); trigger: is the character ("/"); above: opens it
1055
+ # upwards. No items renders nothing. Other options go on the element. Needs
1056
+ # import "unmagic/components/slash_menu".
1057
+ def ai_chat_slash_menu(for: nil, id: nil, trigger: "/", above: false, insert: nil, **options, &block)
1058
+ builder = Components::AIChat::SlashMenu.new(self, for: binding.local_variable_get(:for), id: id,
1059
+ trigger: trigger, above: above, insert: insert, **options)
1060
+ capture(builder, &block) if block
1061
+ builder.render
1062
+ end
1063
+
1064
+ # The controls under a turn.
1065
+ #
1066
+ # <%= ai_chat_action_bar for: dom_id(message) do |bar| %>
1067
+ # <% bar.copy message.content %>
1068
+ # <% bar.action "Try again", retry_message_path(message), icon: :rotate_cw, method: :post %>
1069
+ # <% end %>
1070
+ #
1071
+ # A toolbar: one Tab stop, arrow keys between controls. for: is the turn's id.
1072
+ # reveal: :hover (default) shows it on hover or focus and always on touch;
1073
+ # :always shows it. action takes icon: and method: (:get is a link), plus
1074
+ # confirm:; control takes any markup. No controls renders nothing. Other
1075
+ # options go on the element. Needs import "unmagic/components/toolbar".
1076
+ def ai_chat_action_bar(for:, reveal: :hover, **options, &block)
1077
+ builder = Components::AIChat::ActionBar.new(self, for: binding.local_variable_get(:for), reveal: reveal, **options)
1078
+ capture(builder, &block) if block
1079
+ builder.render
1080
+ end
1081
+
1082
+ # Walking between versions of a turn.
1083
+ #
1084
+ # <%= ai_chat_branch_picker index: 2, count: 3, previous: branch_path(m, 1), next: branch_path(m, 3) %>
1085
+ #
1086
+ # A nil previous: or next: is a disabled end. count: 1 renders nothing.
1087
+ # method: other than :get makes the steps buttons. Other options go on the
1088
+ # <div>. Needs import "unmagic/components/ai_chat" to keep focus on the
1089
+ # picker as the turn is replaced.
1090
+ def ai_chat_branch_picker(index:, count:, previous: nil, next: nil, method: :get, **options)
1091
+ Components::AIChat::BranchPicker.new(self, index: index, count: count, previous: previous,
1092
+ next: binding.local_variable_get(:next), method: method, **options).render
1093
+ end
1094
+
540
1095
  private
541
1096
 
542
1097
  def modal_link_options(html_options)
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module AIChat
6
+ # The controls under a turn: copy it, run it again, edit it. See
7
+ # ActionViewHelpers#ai_chat_action_bar.
8
+ class ActionBar
9
+ REVEALS = %i[hover always].freeze
10
+
11
+ def initialize(view, for:, reveal: :hover, **options)
12
+ target = binding.local_variable_get(:for)
13
+ raise ArgumentError, "ai_chat_action_bar needs for: the id of the turn it acts on" if target.blank?
14
+
15
+ AIChat.validate!("ai_chat_action_bar", :reveal, reveal, REVEALS)
16
+
17
+ @view = view
18
+ @for = target
19
+ @reveal = reveal
20
+ @options = options
21
+ @controls = []
22
+ end
23
+
24
+ def copy(text)
25
+ @controls << view.copy_button(text, class: "UnmagicAIChatActionBar__action")
26
+ nil
27
+ end
28
+
29
+ # Every control is icon-only, so each carries a label and a matching title.
30
+ # A GET is a link; anything else is a button_to.
31
+ def action(label, url, icon:, method: :get, confirm: nil, **options)
32
+ content = Icons.svg(view, icon)
33
+ html = options.merge("aria-label": label, title: label,
34
+ class: view.class_names(Button.classes(:icon), "UnmagicAIChatActionBar__action", options[:class]))
35
+
36
+ @controls << if method == :get
37
+ view.link_to(content, url, **html)
38
+ else
39
+ view.button_to(url, method: method, form: { data: { turbo_confirm: confirm }.compact, class: "UnmagicAIChatActionBar__form" }, **html) { content }
40
+ end
41
+ nil
42
+ end
43
+
44
+ def control(content = nil, &block)
45
+ @controls << (block ? view.capture(&block) : content)
46
+ nil
47
+ end
48
+
49
+ # Hidden with opacity, never display: none, so the bar stays in the tab order
50
+ # and appears the moment it takes focus.
51
+ def render
52
+ return "".html_safe if @controls.empty?
53
+
54
+ view.content_tag("unmagic-toolbar", safe_join(@controls), **@options,
55
+ role: "toolbar",
56
+ "aria-label": AIChat.t("action_bar.label", default: "Message actions"),
57
+ "aria-controls": @for,
58
+ data: { reveal: @reveal }.merge(@options[:data] || {}),
59
+ class: view.class_names("UnmagicAIChatActionBar", @options[:class]))
60
+ end
61
+
62
+ private
63
+
64
+ attr_reader :view
65
+
66
+ delegate :safe_join, to: :view, private: true
67
+ end
68
+ end
69
+ end
70
+ end