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,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module AIChat
6
+ # The agent asking something it can't work out on its own, with the answers
7
+ # right there. One component, two faces: choices, or a form the caller
8
+ # builds. See ActionViewHelpers#ai_chat_request.
9
+ class Request
10
+ STATES = %i[waiting accepted declined cancelled timed_out].freeze
11
+
12
+ OUTCOMES = { accepted: "Submitted", declined: "Declined", cancelled: "Cancelled",
13
+ timed_out: "Timed out" }.freeze
14
+
15
+ OUTCOME_ICONS = { accepted: :circle_check, declined: :ban, cancelled: :circle_x, timed_out: :clock }.freeze
16
+
17
+ Question = Struct.new(:text, :header, :multiple, :options, :picked)
18
+ Option = Struct.new(:label, :description)
19
+
20
+ # Collects a question's options.
21
+ class QuestionBuilder
22
+ attr_reader :options
23
+
24
+ def initialize
25
+ @options = []
26
+ end
27
+
28
+ def option(label, description: nil)
29
+ @options << Option.new(label, description)
30
+ nil
31
+ end
32
+ end
33
+
34
+ def initialize(view, state:, url: nil, method: :patch, prompt: nil, label: nil, scope: :response,
35
+ live: false, **options)
36
+ AIChat.validate!("ai_chat_request", :state, state, STATES)
37
+ raise ArgumentError, "a waiting ai_chat_request needs a url: to answer to" if state == :waiting && url.blank?
38
+
39
+ @view = view
40
+ @state = state
41
+ @url = url
42
+ @method = method
43
+ @prompt = prompt
44
+ @label = label
45
+ @scope = scope
46
+ @live = live
47
+ @options = options
48
+ @questions = []
49
+ @form = nil
50
+ @answers = []
51
+ @decline = nil
52
+ @aside = nil
53
+ end
54
+
55
+ # picked: is what was chosen, for an answered request; the options go, and
56
+ # the question stays, because it is most of what the card is worth later.
57
+ def question(text, header: nil, multiple: false, picked: nil, &block)
58
+ builder = QuestionBuilder.new
59
+ view.capture(builder, &block) if block # capture, so the block's own whitespace stays out of the page
60
+ @questions << Question.new(text, header, multiple, builder.options, Array(picked))
61
+ nil
62
+ end
63
+
64
+ # The caller's own fields, given the form builder while waiting.
65
+ def form(&block)
66
+ @form = block
67
+ nil
68
+ end
69
+
70
+ # What was sent back, for an answered form.
71
+ def answer(label, value)
72
+ @answers << [ label, value ]
73
+ nil
74
+ end
75
+
76
+ def decline(label = nil)
77
+ @decline = label || AIChat.t("request.decline", default: "Decline")
78
+ nil
79
+ end
80
+
81
+ def aside(text)
82
+ @aside = text
83
+ nil
84
+ end
85
+
86
+ def render
87
+ raise ArgumentError, "an ai_chat_request asks with questions or a form, not both" if @questions.any? && @form
88
+
89
+ view.content_tag(:div, **@options,
90
+ role: ("alert" if @live && waiting?),
91
+ class: view.class_names("UnmagicAIChatRequest", "UnmagicAIChatRequest--#{@state}", @options[:class])) do
92
+ safe_join [ head, (tag.p(@prompt, class: "UnmagicAIChatRequest__prompt") if @prompt.present?), body ].compact
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ attr_reader :view
99
+
100
+ delegate :tag, :safe_join, to: :view, private: true
101
+
102
+ def waiting? = @state == :waiting
103
+
104
+ # Past tense once answered: a heading still saying somebody wants something
105
+ # would be the one line in the card that's no longer true.
106
+ def head
107
+ tag.div(class: "UnmagicAIChatRequest__head") do
108
+ safe_join [
109
+ Icons.svg(view, :circle_question_mark),
110
+ tag.span(waiting? ? AIChat.t("request.waiting", default: "Wants to know") : AIChat.t("request.asked", default: "Wanted to know")),
111
+ (outcome unless waiting?)
112
+ ].compact
113
+ end
114
+ end
115
+
116
+ def outcome
117
+ tag.span(class: "UnmagicAIChatRequest__outcome") do
118
+ safe_join [ Icons.svg(view, OUTCOME_ICONS.fetch(@state)), AIChat.t("request.#{@state}", default: OUTCOMES.fetch(@state)) ]
119
+ end
120
+ end
121
+
122
+ def body
123
+ if waiting?
124
+ view.form_with(url: @url, method: @method, scope: @scope, builder: Components::FormBuilder,
125
+ class: "UnmagicAIChatRequest__form") do |form|
126
+ safe_join [ questions, (view.capture(form, &@form) if @form), actions ].compact
127
+ end
128
+ else
129
+ safe_join [ questions, answers ].compact
130
+ end
131
+ end
132
+
133
+ def questions
134
+ return if @questions.empty?
135
+
136
+ safe_join(@questions.each_with_index.map { |question, index| fieldset(question, index) })
137
+ end
138
+
139
+ def fieldset(question, index)
140
+ tag.fieldset(class: "UnmagicAIChatRequest__question") do
141
+ safe_join [
142
+ tag.legend(class: "UnmagicAIChatRequest__legend") do
143
+ safe_join [
144
+ (tag.span(question.header, class: "UnmagicAIChatRequest__header") if question.header.present?),
145
+ tag.span(question.text, class: "UnmagicAIChatRequest__label")
146
+ ].compact
147
+ end,
148
+ waiting? ? choices(question, index) : picked(question)
149
+ ]
150
+ end
151
+ end
152
+
153
+ # A single choice is required, so a question skipped by accident isn't one
154
+ # the agent has to ask again.
155
+ def choices(question, index)
156
+ kind = question.multiple ? :check : :radio
157
+
158
+ tag.div(class: "UnmagicAIChatRequest__options") do
159
+ safe_join(question.options.map do |option|
160
+ tag.label(class: "UnmagicAIChatRequest__option") do
161
+ safe_join [
162
+ tag.input(type: question.multiple ? "checkbox" : "radio", name: "answers[#{index}][]",
163
+ value: option.label, required: !question.multiple, class: Control.classes(view, kind)),
164
+ tag.span(class: "UnmagicAIChatRequest__choice") do
165
+ safe_join [
166
+ tag.span(option.label, class: "UnmagicAIChatRequest__choiceLabel"),
167
+ (tag.span(option.description, class: "UnmagicAIChatRequest__choiceDescription") if option.description.present?)
168
+ ].compact
169
+ end
170
+ ]
171
+ end
172
+ end)
173
+ end
174
+ end
175
+
176
+ def picked(question)
177
+ text = question.picked.presence&.to_sentence || AIChat.t("request.unanswered", default: "No answer.")
178
+ tag.p(text, class: view.class_names("UnmagicAIChatRequest__picked",
179
+ "UnmagicAIChatRequest__picked--none" => question.picked.empty?))
180
+ end
181
+
182
+ def answers
183
+ return if @answers.empty?
184
+
185
+ tag.dl(class: "UnmagicAIChatRequest__answers") do
186
+ safe_join(@answers.map do |label, value|
187
+ tag.div(safe_join([ tag.dt(label), tag.dd(value) ]), class: "UnmagicAIChatRequest__answer")
188
+ end)
189
+ end
190
+ end
191
+
192
+ # Decline posts the same form with a flag and skips validation, so it is one
193
+ # form with no nested button_to and needs nothing filled in.
194
+ def actions
195
+ tag.div(class: "UnmagicAIChatRequest__actions") do
196
+ safe_join [
197
+ tag.button(@label || AIChat.t("request.answer", default: "Answer"), type: "submit", class: Button.classes(:primary)),
198
+ (tag.button(@decline, type: "submit", name: "decline", value: "1", formnovalidate: true, class: Button.classes) if @decline),
199
+ (tag.p(@aside, class: "UnmagicAIChatRequest__aside") if @aside.present?)
200
+ ].compact
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module AIChat
6
+ # The collapsible panel section a plan and a workspace share: a glyph, a
7
+ # title, a count and a chevron over a body. Always rendered, empty or not,
8
+ # because both are broadcast targets.
9
+ class Section
10
+ def initialize(view, block:, icon:, title:, count:, open:, collapsible:, title_tag:, options:)
11
+ @view = view
12
+ @block = block
13
+ @icon = icon
14
+ @title = title
15
+ @count = count
16
+ @open = open
17
+ @collapsible = collapsible
18
+ @title_tag = title_tag
19
+ @options = options
20
+ end
21
+
22
+ def render(body)
23
+ classes = view.class_names(@block, { "#{@block}--static" => !@collapsible }, @options[:class])
24
+
25
+ if @collapsible
26
+ tag.details(**@options, open: @open, class: classes) do
27
+ safe_join [ tag.summary(head(chevron: true), class: "#{@block}__head"), body ]
28
+ end
29
+ else
30
+ tag.section(**@options, class: classes) do
31
+ safe_join [ tag.div(head(chevron: false), class: "#{@block}__head"), body ]
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :view
39
+
40
+ delegate :tag, :safe_join, to: :view, private: true
41
+
42
+ def head(chevron:)
43
+ safe_join [
44
+ Icons.svg(view, @icon, class: "#{@block}__icon"),
45
+ view.content_tag(@title_tag, @title, class: "#{@block}__title"),
46
+ (Icons.svg(view, :chevron_right, class: "UnmagicAIChatChevron") if chevron),
47
+ (tag.span(@count, class: "#{@block}__count") if @count.present?)
48
+ ].compact
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module AIChat
6
+ # Commands offered on a slash, rendered with every item and filtered in the
7
+ # browser, so what can be offered is only ever what exists. See
8
+ # ActionViewHelpers#ai_chat_slash_menu.
9
+ class SlashMenu
10
+ Item = Struct.new(:name, :description, :arguments)
11
+
12
+ def initialize(view, for: nil, id: nil, trigger: "/", above: false, insert: nil, **options)
13
+ raise ArgumentError, "ai_chat_slash_menu trigger must be one character" unless trigger.to_s.length == 1
14
+
15
+ @view = view
16
+ @for = binding.local_variable_get(:for)
17
+ @id = id || AIChat.random_id("slash_menu")
18
+ @trigger = trigger
19
+ @above = above
20
+ @insert = insert
21
+ @options = options
22
+ @items = []
23
+ end
24
+
25
+ def item(name, description: nil, arguments: [])
26
+ @items << Item.new(name.to_s, description, Array(arguments))
27
+ nil
28
+ end
29
+
30
+ # An agent with no commands has no menu, not an empty one.
31
+ def render
32
+ return "".html_safe if @items.empty?
33
+
34
+ label = AIChat.t("slash_menu.label", default: "Commands")
35
+
36
+ view.content_tag("unmagic-slash-menu", **@options,
37
+ for: @for, trigger: @trigger, insert: @insert, hidden: true,
38
+ class: view.class_names("UnmagicAIChatSlashMenu", { "UnmagicAIChatSlashMenu--above" => @above }, @options[:class])) do
39
+ tag.ul(id: "#{@id}_listbox", role: "listbox", "aria-label": label, class: "UnmagicAIChatSlashMenu__list") do
40
+ safe_join(@items.each_with_index.map { |item, index| option(item, index) })
41
+ end
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :view
48
+
49
+ delegate :tag, :safe_join, to: :view, private: true
50
+
51
+ def option(item, index)
52
+ tag.li(id: "#{@id}_#{index}", role: "option", "aria-selected": index.zero?.to_s, data: { name: item.name },
53
+ class: "UnmagicAIChatSlashMenu__item") do
54
+ safe_join [
55
+ tag.span(class: "UnmagicAIChatSlashMenu__name") do
56
+ safe_join [
57
+ "#{@trigger}#{item.name}",
58
+ *item.arguments.map { |argument| tag.span("[#{argument}]", class: "UnmagicAIChatSlashMenu__argument") }
59
+ ], " "
60
+ end,
61
+ (tag.span(item.description, class: "UnmagicAIChatSlashMenu__description") if item.description.present?)
62
+ ].compact
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module AIChat
6
+ # One reach for a tool, from the ask through to the answer, as a row in a
7
+ # timeline. See ActionViewHelpers#ai_chat_tool_call.
8
+ class ToolCall
9
+ STATES = %i[queued running waiting done failed].freeze
10
+
11
+ GLYPHS = { queued: :circle_dashed, running: :loader_circle, waiting: :circle_question_mark,
12
+ done: :circle_check, failed: :circle_x }.freeze
13
+
14
+ LABELS = { queued: "Queued", running: "Running", waiting: "Waiting on you", done: "Done",
15
+ failed: "Failed" }.freeze
16
+
17
+ def initialize(view, name:, state:, id: nil, icon: nil, open: false, timeline: true, **options)
18
+ AIChat.validate!("ai_chat_tool_call", :state, state, STATES)
19
+
20
+ @view = view
21
+ @name = name
22
+ @state = state
23
+ @id = id
24
+ @icon = icon
25
+ @open = open
26
+ @timeline = timeline
27
+ @options = options
28
+ @summary = nil
29
+ @timing = nil
30
+ @failures = nil
31
+ @progress = nil
32
+ @payloads = []
33
+ @made = nil
34
+ end
35
+
36
+ # What the call was actually about, beside the name: three identical labels
37
+ # say nothing, "search_messages · car seat" does.
38
+ def summary(text)
39
+ @summary = text
40
+ nil
41
+ end
42
+
43
+ # A live clock while running, what it took once done, nothing if neither is
44
+ # known — a zero would be a reading, and there isn't one.
45
+ def timing(started_at: nil, duration: nil)
46
+ @timing = { started_at: started_at, duration: duration }
47
+ nil
48
+ end
49
+
50
+ def asked(payload, **options)
51
+ @payloads << Payload.new(view, payload, label: AIChat.t("tool_call.asked", default: "Asked"), **options)
52
+ nil
53
+ end
54
+
55
+ def answered(payload, **options)
56
+ @payloads << Payload.new(view, payload, label: AIChat.t("tool_call.answered", default: "Answered"), **options)
57
+ nil
58
+ end
59
+
60
+ # How much of a batch didn't work, for a call that otherwise did. Nothing for
61
+ # zero, and nothing for a call that failed outright, which already says so.
62
+ def failures(count)
63
+ @failures = count
64
+ nil
65
+ end
66
+
67
+ # What the tool is saying while it works. Rendered with its own id so the host
68
+ # can replace just this line as reports come in.
69
+ def progress(text)
70
+ @progress = text
71
+ nil
72
+ end
73
+
74
+ # What the call produced. It stays outside the fold: it is the reason the
75
+ # call happened, and hiding it behind a chevron is the wrong way round.
76
+ def made(content = nil, &block)
77
+ @made = block ? view.capture(&block) : content
78
+ nil
79
+ end
80
+
81
+ def render
82
+ payloads = @payloads.map(&:render).select(&:present?)
83
+
84
+ view.content_tag(:div, **@options,
85
+ id: @id,
86
+ "aria-busy": ("true" if @state == :running),
87
+ data: { ai_chat_timeline: ("row" if @timeline) }.merge(@options[:data] || {}),
88
+ class: view.class_names("UnmagicAIChatToolCall", "UnmagicAIChatToolCall--#{@state}", @options[:class])) do
89
+ safe_join [
90
+ (tag.span(class: "UnmagicAIChatToolCall__join", "aria-hidden": "true") if @timeline),
91
+ payloads.any? ? disclosure(payloads) : tag.div(row(chevron: false), class: "UnmagicAIChatToolCall__row"),
92
+ (tag.div(safe_join([ rail, @made ]), class: "UnmagicAIChatToolCall__made") if @made.present?)
93
+ ].compact
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ attr_reader :view
100
+
101
+ delegate :tag, :safe_join, to: :view, private: true
102
+
103
+ # <details> so the disclosure needs no script and no state to restore after
104
+ # a broadcast replaces the row, which happens on every change of state.
105
+ def disclosure(payloads)
106
+ tag.details(open: @open, class: "UnmagicAIChatToolCall__disclosure") do
107
+ safe_join [
108
+ tag.summary(row(chevron: true), class: "UnmagicAIChatToolCall__row"),
109
+ tag.div(safe_join([ rail, *payloads ]), class: "UnmagicAIChatToolCall__body")
110
+ ]
111
+ end
112
+ end
113
+
114
+ def row(chevron:)
115
+ safe_join [
116
+ glyph,
117
+ tag.code(@name, class: "UnmagicAIChatToolCall__name"),
118
+ (tag.span(@summary, class: "UnmagicAIChatToolCall__summary") if @summary.present?),
119
+ (Icons.svg(view, :chevron_right, class: "UnmagicAIChatChevron") if chevron),
120
+ readings,
121
+ (progress_line if @state == :running && @progress.present?)
122
+ ].compact
123
+ end
124
+
125
+ # A call that simply worked is the common case, so its glyph says what it was
126
+ # about (icon:) rather than repeating a tick down the whole timeline.
127
+ def glyph
128
+ name = @state == :done && @icon ? @icon : GLYPHS.fetch(@state)
129
+
130
+ tag.span(class: "UnmagicAIChatToolCall__glyph") do
131
+ safe_join [
132
+ Icons.svg(view, name, class: ("UnmagicAIChatSpinner" if @state == :running)),
133
+ tag.span(AIChat.t("tool_call.#{@state}", default: LABELS.fetch(@state)), class: "UnmagicVisuallyHidden")
134
+ ]
135
+ end
136
+ end
137
+
138
+ def readings
139
+ items = [ failures_reading, timing_reading ].compact
140
+ tag.span(safe_join(items), class: "UnmagicAIChatToolCall__readings") if items.any?
141
+ end
142
+
143
+ def failures_reading
144
+ return if @failures.to_i.zero? || @state == :failed
145
+
146
+ reading(:triangle_alert, AIChat.t("tool_call.partly_failed", default: "Partly failed"),
147
+ AIChat.t("tool_call.failures", count: @failures, default: "%{count} failed"), modifier: "warn")
148
+ end
149
+
150
+ def timing_reading
151
+ return unless @timing
152
+
153
+ if @state == :running && @timing[:started_at]
154
+ reading(:timer, AIChat.t("tool_call.running_for", default: "Running for"),
155
+ Elapsed.new(view, @timing[:started_at], direction: :up).render)
156
+ elsif @timing[:duration]
157
+ reading(:timer, AIChat.t("tool_call.took", default: "Took"), Duration.format(@timing[:duration]))
158
+ end
159
+ end
160
+
161
+ # Each reading leads with a glyph, so a run of small grey numbers can be told
162
+ # apart before the digits are read.
163
+ def reading(icon, label, value, modifier: nil)
164
+ tag.span(class: view.class_names("UnmagicAIChatToolCall__reading",
165
+ "UnmagicAIChatToolCall__reading--#{modifier}" => modifier)) do
166
+ safe_join [ Icons.svg(view, icon), tag.span(label, class: "UnmagicVisuallyHidden"), value ]
167
+ end
168
+ end
169
+
170
+ def progress_line
171
+ tag.span(@progress, id: ("#{@id}_progress" if @id), class: "UnmagicAIChatToolCall__progress")
172
+ end
173
+
174
+ # The timeline carried down the side of whatever hangs under a row.
175
+ def rail
176
+ tag.span(class: "UnmagicAIChatToolCall__rail", "aria-hidden": "true")
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module AIChat
6
+ # The scrolling region a conversation's turns are rendered into, and the
7
+ # spacing between them. It renders no entries of its own: the host keeps a
8
+ # partial per entry type. See ActionViewHelpers#ai_chat.
9
+ class Transcript
10
+ def initialize(view, id:, scroller: nil, follow: true, scroll_to_latest: true, **options)
11
+ raise ArgumentError, "ai_chat needs an id: for entries to be broadcast into" if id.blank?
12
+
13
+ @view = view
14
+ @id = id
15
+ @scroller = scroller
16
+ @follow = follow
17
+ @scroll_to_latest = scroll_to_latest
18
+ @options = options
19
+ @welcome = nil
20
+ end
21
+
22
+ # Shown while the conversation is empty. It renders inside the log, so the
23
+ # first entry streamed in hides it (see the CSS) without another request.
24
+ def welcome(content = nil, &block)
25
+ @welcome = block ? view.capture(&block) : content
26
+ nil
27
+ end
28
+
29
+ # Always renders the log, empty or not: a broadcast can only land in an
30
+ # element that is already on the page.
31
+ def render(entries)
32
+ log = view.content_tag(:div, **@options,
33
+ id: @id,
34
+ role: "log",
35
+ "aria-live": "polite",
36
+ "aria-relevant": "additions",
37
+ "aria-label": AIChat.t("transcript.label", default: "Conversation"),
38
+ class: view.class_names("UnmagicAIChat", @options[:class])) do
39
+ safe_join [ (tag.div(@welcome, class: "UnmagicAIChat__welcome") if @welcome && entries.blank?), entries ].compact
40
+ end
41
+
42
+ return log unless @follow
43
+
44
+ view.content_tag("unmagic-autoscroll", scroller: @scroller, class: "UnmagicAutoscroll") do
45
+ safe_join [ log, (latest if @scroll_to_latest) ].compact
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :view
52
+
53
+ delegate :tag, :safe_join, to: :view, private: true
54
+
55
+ # A real button in the tab order, shown by <unmagic-autoscroll> only while
56
+ # the reader has scrolled away from the bottom.
57
+ def latest
58
+ label = AIChat.t("transcript.latest", default: "Jump to latest")
59
+
60
+ tag.div(class: "UnmagicAIChat__latest") do
61
+ tag.button(Icons.svg(view, :arrow_down), type: "button", hidden: true, "data-autoscroll-latest": "",
62
+ "aria-label": label, title: label, class: "UnmagicAIChat__latestButton")
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module AIChat
6
+ # What an empty conversation says, and the suggestions that start one. See
7
+ # ActionViewHelpers#ai_chat_welcome.
8
+ class Welcome
9
+ Suggestion = Struct.new(:label, :icon, :fill, :value)
10
+
11
+ def initialize(view, heading:, heading_tag: :h2, composer: "composer", field: nil, **options)
12
+ @view = view
13
+ @heading = heading
14
+ @heading_tag = heading_tag
15
+ @composer = composer
16
+ @field = field
17
+ @options = options
18
+ @body = nil
19
+ @suggestions = []
20
+ end
21
+
22
+ def body(text)
23
+ @body = text
24
+ nil
25
+ end
26
+
27
+ # fill: true puts the text in the box without sending it, for a prompt that
28
+ # can't be sent as it stands.
29
+ def suggestion(label, icon: nil, fill: false, value: nil)
30
+ @suggestions << Suggestion.new(label, icon, fill, value)
31
+ nil
32
+ end
33
+
34
+ def render
35
+ view.content_tag(:div, **@options, class: view.class_names("UnmagicAIChatWelcome", @options[:class])) do
36
+ safe_join [
37
+ view.content_tag(@heading_tag, @heading, class: "UnmagicAIChatWelcome__heading"),
38
+ (tag.p(@body, class: "UnmagicAIChatWelcome__body") if @body.present?),
39
+ suggestions
40
+ ].compact
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :view
47
+
48
+ delegate :tag, :safe_join, to: :view, private: true
49
+
50
+ # Buttons, not links: a suggestion puts text in a box on this page.
51
+ def suggestions
52
+ return if @suggestions.empty?
53
+
54
+ tag.ul(class: "UnmagicAIChatWelcome__suggestions") do
55
+ safe_join(@suggestions.map do |suggestion|
56
+ tag.li do
57
+ tag.button(type: "button", class: "UnmagicAIChatWelcome__suggestion",
58
+ data: {
59
+ ai_chat_suggestion: suggestion.value || suggestion.label,
60
+ ai_chat_suggestion_form: @composer,
61
+ ai_chat_suggestion_field: @field,
62
+ ai_chat_suggestion_fill: ("" if suggestion.fill)
63
+ }.compact) do
64
+ safe_join [ (Icons.svg(view, suggestion.icon) if suggestion.icon), suggestion.label ].compact
65
+ end
66
+ end
67
+ end)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end