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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +98 -1
- data/README.md +506 -1
- data/app/assets/icons/lucide/LICENSE +43 -0
- data/app/assets/icons/lucide/arrow-down.svg +1 -0
- data/app/assets/icons/lucide/arrow-left.svg +1 -0
- data/app/assets/icons/lucide/arrow-up.svg +1 -0
- data/app/assets/icons/lucide/ban.svg +1 -0
- data/app/assets/icons/lucide/brain.svg +1 -0
- data/app/assets/icons/lucide/check.svg +1 -0
- data/app/assets/icons/lucide/chevron-down.svg +1 -0
- data/app/assets/icons/lucide/chevron-left.svg +1 -0
- data/app/assets/icons/lucide/chevron-right.svg +1 -0
- data/app/assets/icons/lucide/circle-check.svg +1 -0
- data/app/assets/icons/lucide/circle-dashed.svg +1 -0
- data/app/assets/icons/lucide/circle-question-mark.svg +1 -0
- data/app/assets/icons/lucide/circle-stop.svg +1 -0
- data/app/assets/icons/lucide/circle-x.svg +1 -0
- data/app/assets/icons/lucide/clock.svg +1 -0
- data/app/assets/icons/lucide/copy.svg +1 -0
- data/app/assets/icons/lucide/ellipsis-vertical.svg +1 -0
- data/app/assets/icons/lucide/file.svg +1 -0
- data/app/assets/icons/lucide/folder.svg +1 -0
- data/app/assets/icons/lucide/grip-vertical.svg +1 -0
- data/app/assets/icons/lucide/info.svg +1 -0
- data/app/assets/icons/lucide/lightbulb.svg +1 -0
- data/app/assets/icons/lucide/list-checks.svg +1 -0
- data/app/assets/icons/lucide/loader-circle.svg +1 -0
- data/app/assets/icons/lucide/messages-square.svg +1 -0
- data/app/assets/icons/lucide/paperclip.svg +1 -0
- data/app/assets/icons/lucide/pencil.svg +1 -0
- data/app/assets/icons/lucide/plus.svg +1 -0
- data/app/assets/icons/lucide/rotate-cw.svg +1 -0
- data/app/assets/icons/lucide/shield-alert.svg +1 -0
- data/app/assets/icons/lucide/timer.svg +1 -0
- data/app/assets/icons/lucide/triangle-alert.svg +1 -0
- data/app/assets/icons/lucide/wrench.svg +1 -0
- data/app/assets/icons/lucide/x.svg +1 -0
- data/app/assets/javascripts/unmagic/components/ai_chat.js +142 -0
- data/app/assets/javascripts/unmagic/components/autoscroll.js +103 -0
- data/app/assets/javascripts/unmagic/components/board.js +65 -0
- data/app/assets/javascripts/unmagic/components/dropzone.js +206 -0
- data/app/assets/javascripts/unmagic/components/elapsed.js +64 -0
- data/app/assets/javascripts/unmagic/components/optimistic.js +71 -0
- data/app/assets/javascripts/unmagic/components/placement.js +30 -0
- data/app/assets/javascripts/unmagic/components/slash_menu.js +186 -0
- data/app/assets/javascripts/unmagic/components/sortable.js +6 -0
- data/app/assets/javascripts/unmagic/components/sortable_item.js +122 -0
- data/app/assets/javascripts/unmagic/components/sortable_list.js +135 -0
- data/app/assets/javascripts/unmagic/components/sortable_param.js +14 -0
- data/app/assets/javascripts/unmagic/components/sortable_session.js +502 -0
- data/app/assets/javascripts/unmagic/components/streaming_markdown.js +269 -0
- data/app/assets/javascripts/unmagic/components/toasts.js +14 -4
- data/app/assets/javascripts/unmagic/components/toolbar.js +76 -0
- data/app/assets/javascripts/unmagic/components/upsert.js +4 -20
- data/app/assets/javascripts/unmagic/components.js +10 -0
- data/app/assets/tailwind/unmagic_components/engine.css +1515 -0
- data/lib/unmagic/components/action_view_helpers.rb +558 -3
- data/lib/unmagic/components/ai_chat/action_bar.rb +70 -0
- data/lib/unmagic/components/ai_chat/attachments.rb +119 -0
- data/lib/unmagic/components/ai_chat/branch_picker.rb +65 -0
- data/lib/unmagic/components/ai_chat/citation.rb +89 -0
- data/lib/unmagic/components/ai_chat/composer.rb +160 -0
- data/lib/unmagic/components/ai_chat/failure.rb +80 -0
- data/lib/unmagic/components/ai_chat/message.rb +142 -0
- data/lib/unmagic/components/ai_chat/payload.rb +93 -0
- data/lib/unmagic/components/ai_chat/permission.rb +143 -0
- data/lib/unmagic/components/ai_chat/plan.rb +91 -0
- data/lib/unmagic/components/ai_chat/proposal.rb +102 -0
- data/lib/unmagic/components/ai_chat/reasoning.rb +75 -0
- data/lib/unmagic/components/ai_chat/request.rb +206 -0
- data/lib/unmagic/components/ai_chat/section.rb +53 -0
- data/lib/unmagic/components/ai_chat/slash_menu.rb +68 -0
- data/lib/unmagic/components/ai_chat/tool_call.rb +181 -0
- data/lib/unmagic/components/ai_chat/transcript.rb +68 -0
- data/lib/unmagic/components/ai_chat/welcome.rb +73 -0
- data/lib/unmagic/components/ai_chat/workspace.rb +74 -0
- data/lib/unmagic/components/ai_chat.rb +31 -0
- data/lib/unmagic/components/avatar.rb +134 -0
- data/lib/unmagic/components/board.rb +182 -0
- data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/application_controller.rb +80 -0
- data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/demos_controller.rb +137 -0
- data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/pages_controller.rb +40 -0
- data/lib/unmagic/components/browser/app/helpers/unmagic/components/browser/application_helper.rb +52 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/board_store.rb +88 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/catalog.rb +65 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/pager.rb +11 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/profile.rb +25 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/reply.rb +72 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/thing.rb +31 -0
- data/lib/unmagic/components/browser/app/views/layouts/unmagic/components/browser/application.html.erb +65 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_code.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_crumb.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_example.html.erb +37 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_nav.html.erb +17 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/demos/edit_profile.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_conversation.html.erb +101 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_live.html.erb +53 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_welcome.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_always.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_hover.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_dropzone.html.erb +17 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_sent.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_thumbnail.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_branch_picker/_positions.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_branch_picker/_thumbnail.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_compact.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_quote.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_parts.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_states.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_full.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_plain.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_parts.html.erb +23 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_states.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_turns.html.erb +16 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_json.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_text.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_thumbnail.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_answered.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_waiting.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_empty.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_states.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_decided.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_inline.html.erb +18 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_thumbnail.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_settled.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_thinking.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_thumbnail.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_answered.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_choices.html.erb +13 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_form.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_thumbnail.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_slash_menu/_composer.html.erb +15 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_slash_menu/_thumbnail.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_made.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_run.html.erb +25 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_states.html.erb +22 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_welcome/_suggestions.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_welcome/_thumbnail.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_workspace/_files.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_workspace/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_group.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_sizes.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_tints.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_in_context.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_tones.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_handles.html.erb +13 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_live.html.erb +34 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_thumbnail.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_sizes.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_variants.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_neutral.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_tones.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_actions_and_footer.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_flush_table.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_link.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_thumbnail.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_danger.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_plain.html.erb +2 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_from_element.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_with_label.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_inline.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_stacked.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_dialog_tag.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_modal.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_countdown.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_running.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/empty_state/_default.html.erb +1 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/empty_state/_thumbnail.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_autogrow.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_control_classes.html.erb +54 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_fields.html.erb +28 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_states.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_thumbnail.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_uuid_field.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_absolute.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_relative.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_thumbnail.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_actions.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_labelled.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_thumbnail.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_default.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_with_leading.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_blocking_out.html.erb +25 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_card.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_detail_list.html.erb +15 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_page_header.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_text_and_button.html.erb +17 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_thumbnail.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_connected.html.erb +17 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_grid.html.erb +20 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_handles.html.erb +22 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_reveal.html.erb +19 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_stopped.html.erb +31 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_deferred.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_empty.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_sortable.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_table_tag.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_links.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_panels.html.erb +18 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_above_dialog.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_flash.html.erb +13 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_stream.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_thumbnail.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_placement.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_term.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/component.html.erb +58 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/installation.html.erb +47 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/overview.html.erb +49 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/theming.html.erb +52 -0
- data/lib/unmagic/components/browser/assets/browser.css +2 -0
- data/lib/unmagic/components/browser/catalog/ai_chat.rb +21 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_action_bar.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_attachments.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_branch_picker.rb +12 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_citation.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_composer.rb +16 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_failure.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_message.rb +17 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_payload.rb +16 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_permission.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_plan.rb +14 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_proposal.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_reasoning.rb +14 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_request.rb +17 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_slash_menu.rb +14 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_tool_call.rb +17 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_welcome.rb +13 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_workspace.rb +12 -0
- data/lib/unmagic/components/browser/catalog/avatar.rb +15 -0
- data/lib/unmagic/components/browser/catalog/badge.rb +11 -0
- data/lib/unmagic/components/browser/catalog/board.rb +18 -0
- data/lib/unmagic/components/browser/catalog/button.rb +14 -0
- data/lib/unmagic/components/browser/catalog/callout.rb +13 -0
- data/lib/unmagic/components/browser/catalog/card.rb +16 -0
- data/lib/unmagic/components/browser/catalog/confirm.rb +14 -0
- data/lib/unmagic/components/browser/catalog/copy_button.rb +15 -0
- data/lib/unmagic/components/browser/catalog/detail_list.rb +14 -0
- data/lib/unmagic/components/browser/catalog/dialog.rb +15 -0
- data/lib/unmagic/components/browser/catalog/elapsed.rb +14 -0
- data/lib/unmagic/components/browser/catalog/empty_state.rb +11 -0
- data/lib/unmagic/components/browser/catalog/forms.rb +21 -0
- data/lib/unmagic/components/browser/catalog/local_time.rb +13 -0
- data/lib/unmagic/components/browser/catalog/menu.rb +15 -0
- data/lib/unmagic/components/browser/catalog/page_header.rb +13 -0
- data/lib/unmagic/components/browser/catalog/skeleton.rb +18 -0
- data/lib/unmagic/components/browser/catalog/sortable_list.rb +17 -0
- data/lib/unmagic/components/browser/catalog/streaming_markdown.rb +17 -0
- data/lib/unmagic/components/browser/catalog/table.rb +17 -0
- data/lib/unmagic/components/browser/catalog/tabs.rb +15 -0
- data/lib/unmagic/components/browser/catalog/toast.rb +17 -0
- data/lib/unmagic/components/browser/catalog/tooltip.rb +14 -0
- data/lib/unmagic/components/browser/config/routes.rb +41 -0
- data/lib/unmagic/components/browser/tailwind/browser.css +33 -0
- data/lib/unmagic/components/browser.rb +39 -0
- data/lib/unmagic/components/configuration.rb +31 -1
- data/lib/unmagic/components/duration.rb +35 -0
- data/lib/unmagic/components/elapsed.rb +46 -0
- data/lib/unmagic/components/engine.rb +9 -0
- data/lib/unmagic/components/icons.rb +34 -26
- data/lib/unmagic/components/modal.rb +1 -1
- data/lib/unmagic/components/page_header.rb +8 -0
- data/lib/unmagic/components/sortable.rb +104 -0
- data/lib/unmagic/components/streaming_markdown.rb +34 -0
- data/lib/unmagic/components/turbo_stream_actions.rb +16 -0
- data/lib/unmagic/components/version.rb +1 -1
- data/lib/unmagic/components.rb +30 -1
- 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,
|
|
358
|
-
#
|
|
359
|
-
#
|
|
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
|