protege 0.1.0.alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +24 -0
- data/Rakefile +12 -0
- data/app/assets/builds/protege.css +1128 -0
- data/app/assets/javascripts/protege/attachment_picker.js +163 -0
- data/app/assets/javascripts/protege/copy.js +80 -0
- data/app/assets/javascripts/protege/forms.js +28 -0
- data/app/assets/javascripts/protege/keys.js +212 -0
- data/app/assets/javascripts/protege/resize.js +149 -0
- data/app/assets/javascripts/protege/theme.js +25 -0
- data/app/assets/stylesheets/protege/application.css +15 -0
- data/app/assets/tailwind/application.css +137 -0
- data/app/controllers/concerns/protege/persona_scoped.rb +22 -0
- data/app/controllers/concerns/protege/thread_scoped.rb +22 -0
- data/app/controllers/protege/access_rules_controller.rb +72 -0
- data/app/controllers/protege/application_controller.rb +18 -0
- data/app/controllers/protege/archives_controller.rb +52 -0
- data/app/controllers/protege/email_domains_controller.rb +72 -0
- data/app/controllers/protege/home_controller.rb +15 -0
- data/app/controllers/protege/messages_controller.rb +46 -0
- data/app/controllers/protege/mta_domains_syncs_controller.rb +22 -0
- data/app/controllers/protege/personas_controller.rb +118 -0
- data/app/controllers/protege/replies_controller.rb +64 -0
- data/app/controllers/protege/responsibilities_controller.rb +82 -0
- data/app/controllers/protege/responsibility_runs_controller.rb +46 -0
- data/app/controllers/protege/threads_controller.rb +75 -0
- data/app/controllers/protege/traces/searches_controller.rb +19 -0
- data/app/controllers/protege/traces_controller.rb +52 -0
- data/app/helpers/protege/application_helper.rb +30 -0
- data/app/helpers/protege/components/badges_helper.rb +59 -0
- data/app/helpers/protege/components/buttons_helper.rb +141 -0
- data/app/helpers/protege/components/cards_helper.rb +48 -0
- data/app/helpers/protege/components/components_helper.rb +30 -0
- data/app/helpers/protege/components/dialogs_helper.rb +36 -0
- data/app/helpers/protege/components/forms_helper.rb +223 -0
- data/app/helpers/protege/components/hotkeys_helper.rb +60 -0
- data/app/helpers/protege/components/icons_helper.rb +41 -0
- data/app/helpers/protege/components/introspection_helper.rb +162 -0
- data/app/helpers/protege/components/layout_helper.rb +169 -0
- data/app/helpers/protege/components/menus_helper.rb +68 -0
- data/app/helpers/protege/components/pagination_helper.rb +100 -0
- data/app/helpers/protege/components/sidebar_helper.rb +211 -0
- data/app/helpers/protege/components/tables_helper.rb +59 -0
- data/app/helpers/protege/components/typography_helper.rb +64 -0
- data/app/helpers/protege/email_domains_helper.rb +22 -0
- data/app/helpers/protege/messages_helper.rb +152 -0
- data/app/helpers/protege/personas_helper.rb +90 -0
- data/app/helpers/protege/responsibilities_helper.rb +63 -0
- data/app/helpers/protege/threads_helper.rb +118 -0
- data/app/helpers/protege/trace_helper.rb +22 -0
- data/app/hooks/protege/event_logger_hook.rb +109 -0
- data/app/jobs/protege/application_job.rb +41 -0
- data/app/jobs/protege/console_inference_job.rb +57 -0
- data/app/jobs/protege/inference_job.rb +60 -0
- data/app/jobs/protege/mail_provisioning_job.rb +21 -0
- data/app/jobs/protege/responsibility_job.rb +70 -0
- data/app/jobs/protege/responsibility_tick_job.rb +19 -0
- data/app/mailboxes/protege/agent_mailbox.rb +123 -0
- data/app/mailers/protege/application_mailer.rb +65 -0
- data/app/models/concerns/protege/broadcastable_email_domain.rb +48 -0
- data/app/models/concerns/protege/broadcastable_message.rb +47 -0
- data/app/models/concerns/protege/broadcastable_persona.rb +50 -0
- data/app/models/concerns/protege/broadcastable_responsibility.rb +51 -0
- data/app/models/concerns/protege/broadcastable_thread.rb +55 -0
- data/app/models/concerns/protege/tool_scoped.rb +88 -0
- data/app/models/protege/access_rule.rb +50 -0
- data/app/models/protege/application_record.rb +18 -0
- data/app/models/protege/current.rb +23 -0
- data/app/models/protege/email_domain.rb +136 -0
- data/app/models/protege/email_thread.rb +81 -0
- data/app/models/protege/message.rb +401 -0
- data/app/models/protege/persona.rb +229 -0
- data/app/models/protege/responsibility.rb +98 -0
- data/app/models/protege/responsibility_run.rb +68 -0
- data/app/models/protege/tool_use.rb +115 -0
- data/app/models/protege/trace.rb +71 -0
- data/app/providers/protege/open_router_provider.rb +317 -0
- data/app/resolvers/README.md +255 -0
- data/app/resolvers/protege/load_file_resolver.rb +86 -0
- data/app/resolvers/protege/load_record_resolver.rb +80 -0
- data/app/resolvers/protege/load_text_resolver.rb +40 -0
- data/app/resolvers/protege/thread_history_resolver.rb +116 -0
- data/app/services/protege/introspection_tool_call_presentation.rb +134 -0
- data/app/services/protege/message_search.rb +114 -0
- data/app/services/protege/trace_search.rb +102 -0
- data/app/subscribers/protege/introspection_broadcaster.rb +324 -0
- data/app/tools/protege/create_file_tool.rb +109 -0
- data/app/tools/protege/read_attachment_tool.rb +101 -0
- data/app/tools/protege/search_emails_tool.rb +129 -0
- data/app/tools/protege/send_email_tool.rb +240 -0
- data/app/tools/protege/web_fetch_tool.rb +224 -0
- data/app/tools/protege/web_search_tool.rb +131 -0
- data/app/views/layouts/protege/application.html.slim +19 -0
- data/app/views/protege/access_rules/create.turbo_stream.slim +6 -0
- data/app/views/protege/email_domains/_email_domain_sidebar_item.html.slim +4 -0
- data/app/views/protege/email_domains/_form.html.slim +10 -0
- data/app/views/protege/email_domains/_sidebar.html.slim +11 -0
- data/app/views/protege/email_domains/index.html.slim +6 -0
- data/app/views/protege/email_domains/new.html.slim +7 -0
- data/app/views/protege/email_domains/show.html.slim +36 -0
- data/app/views/protege/home/show.html.slim +26 -0
- data/app/views/protege/messages/_message.html.slim +19 -0
- data/app/views/protege/messages/_sidebar.html.slim +13 -0
- data/app/views/protege/messages/index.html.slim +20 -0
- data/app/views/protege/personas/_access_rule.html.slim +7 -0
- data/app/views/protege/personas/_access_rule_form.html.slim +8 -0
- data/app/views/protege/personas/_access_rules.html.slim +19 -0
- data/app/views/protege/personas/_actions.html.slim +15 -0
- data/app/views/protege/personas/_form.html.slim +29 -0
- data/app/views/protege/personas/_persona_sidebar_item.html.slim +5 -0
- data/app/views/protege/personas/_sidebar.html.slim +11 -0
- data/app/views/protege/personas/edit.html.slim +7 -0
- data/app/views/protege/personas/index.html.slim +6 -0
- data/app/views/protege/personas/new.html.slim +7 -0
- data/app/views/protege/personas/show.html.slim +37 -0
- data/app/views/protege/responsibilities/_form.html.slim +21 -0
- data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +5 -0
- data/app/views/protege/responsibilities/_sidebar.html.slim +11 -0
- data/app/views/protege/responsibilities/edit.html.slim +7 -0
- data/app/views/protege/responsibilities/index.html.slim +6 -0
- data/app/views/protege/responsibilities/new.html.slim +7 -0
- data/app/views/protege/responsibilities/show.html.slim +22 -0
- data/app/views/protege/responsibility_runs/show.html.slim +13 -0
- data/app/views/protege/shared/_confirm_dialog.html.slim +22 -0
- data/app/views/protege/shared/_header.html.slim +14 -0
- data/app/views/protege/shared/_hotkeys.html.slim +43 -0
- data/app/views/protege/threads/_composer_footer.html.slim +15 -0
- data/app/views/protege/threads/_introspection.html.slim +7 -0
- data/app/views/protege/threads/_reasoning.html.slim +4 -0
- data/app/views/protege/threads/_reply_form.html.slim +7 -0
- data/app/views/protege/threads/_run_marker.html.slim +3 -0
- data/app/views/protege/threads/_sidebar.html.slim +15 -0
- data/app/views/protege/threads/_thread.html.slim +6 -0
- data/app/views/protege/threads/_tool_call.html.slim +8 -0
- data/app/views/protege/threads/index.html.slim +6 -0
- data/app/views/protege/threads/new.html.slim +15 -0
- data/app/views/protege/threads/show.html.slim +25 -0
- data/app/views/protege/traces/_sidebar.html.slim +15 -0
- data/app/views/protege/traces/index.html.slim +11 -0
- data/app/views/protege/traces/searches/_sidebar.html.slim +13 -0
- data/app/views/protege/traces/searches/show.html.slim +22 -0
- data/app/views/protege/traces/show.html.slim +28 -0
- data/config/routes.rb +33 -0
- data/db/migrate/20260520000001_create_protege_tables.rb +105 -0
- data/db/migrate/20260706120130_create_protege_traces.rb +30 -0
- data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +24 -0
- data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +22 -0
- data/lib/generators/protege/extension_naming.rb +38 -0
- data/lib/generators/protege/hook/hook_generator.rb +33 -0
- data/lib/generators/protege/hook/templates/hook.rb.tt +36 -0
- data/lib/generators/protege/install/install_generator.rb +170 -0
- data/lib/generators/protege/install/templates/initializer.rb.tt +84 -0
- data/lib/generators/protege/persona/persona_generator.rb +35 -0
- data/lib/generators/protege/persona/templates/persona.rb.tt +31 -0
- data/lib/generators/protege/postfix/postfix_generator.rb +74 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/Dockerfile +43 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/KeyTable +3 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +135 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/README.md +56 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/SigningTable +3 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/TrustedHosts +8 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/entrypoint.sh +118 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/kamal-accessory.example.yml +32 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/main.cf +68 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendkim.conf +36 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.conf +32 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.ignore.hosts +9 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/policyd-spf.conf +21 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/provision.py +182 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/relay-to-app.sh +28 -0
- data/lib/generators/protege/postfix/templates/github/workflows/build-mail-image.example.yml +50 -0
- data/lib/generators/protege/provider/provider_generator.rb +44 -0
- data/lib/generators/protege/provider/templates/provider.rb.tt +50 -0
- data/lib/generators/protege/resolver/resolver_generator.rb +34 -0
- data/lib/generators/protege/resolver/templates/resolver.rb.tt +31 -0
- data/lib/generators/protege/tool/templates/tool.rb.tt +50 -0
- data/lib/generators/protege/tool/tool_generator.rb +34 -0
- data/lib/protege/configuration.rb +171 -0
- data/lib/protege/engine.rb +103 -0
- data/lib/protege/errors/bad_request_error.rb +8 -0
- data/lib/protege/errors/contract_violation_error.rb +8 -0
- data/lib/protege/errors/error.rb +30 -0
- data/lib/protege/errors/generic_error.rb +7 -0
- data/lib/protege/errors/internal_error.rb +8 -0
- data/lib/protege/errors/invalid_model_error.rb +8 -0
- data/lib/protege/errors/invalid_tool_result_error.rb +9 -0
- data/lib/protege/errors/rate_limited_error.rb +8 -0
- data/lib/protege/errors/resolver_file_not_found_error.rb +9 -0
- data/lib/protege/errors/response_parse_failed_error.rb +8 -0
- data/lib/protege/errors/timeout_error.rb +8 -0
- data/lib/protege/errors/tool_not_available_error.rb +12 -0
- data/lib/protege/errors/tool_not_found_error.rb +9 -0
- data/lib/protege/errors/unauthorized_error.rb +8 -0
- data/lib/protege/errors/unavailable_error.rb +8 -0
- data/lib/protege/errors/unsupported_capability_error.rb +8 -0
- data/lib/protege/events/event.rb +98 -0
- data/lib/protege/events/inference_chunk_event.rb +17 -0
- data/lib/protege/events/inference_completed_event.rb +12 -0
- data/lib/protege/events/inference_failed_event.rb +12 -0
- data/lib/protege/events/inference_generated_event.rb +19 -0
- data/lib/protege/events/inference_max_turns_reached_event.rb +12 -0
- data/lib/protege/events/inference_started_event.rb +11 -0
- data/lib/protege/events/loop_run_completed_event.rb +11 -0
- data/lib/protege/events/loop_run_enqueued_event.rb +12 -0
- data/lib/protege/events/loop_run_failed_event.rb +12 -0
- data/lib/protege/events/loop_run_started_event.rb +11 -0
- data/lib/protege/events/tool_call_completed_event.rb +12 -0
- data/lib/protege/events/tool_call_failed_event.rb +12 -0
- data/lib/protege/events/tool_call_started_event.rb +11 -0
- data/lib/protege/events/tool_calls_received_event.rb +11 -0
- data/lib/protege/extensions/hook.rb +13 -0
- data/lib/protege/extensions/hook_mixin.rb +90 -0
- data/lib/protege/extensions/provider.rb +12 -0
- data/lib/protege/extensions/provider_mixin.rb +124 -0
- data/lib/protege/extensions/resolver.rb +12 -0
- data/lib/protege/extensions/resolver_mixin.rb +58 -0
- data/lib/protege/extensions/tool.rb +12 -0
- data/lib/protege/extensions/tool_mixin.rb +217 -0
- data/lib/protege/gateway/access_control.rb +66 -0
- data/lib/protege/gateway/access_policy.rb +94 -0
- data/lib/protege/gateway/attachment_policy.rb +115 -0
- data/lib/protege/gateway/mail/address.rb +145 -0
- data/lib/protege/gateway/mail/attachment.rb +17 -0
- data/lib/protege/gateway/mail/message_id.rb +104 -0
- data/lib/protege/gateway/mail/outbound.rb +181 -0
- data/lib/protege/gateway/mail/references.rb +107 -0
- data/lib/protege/gateway.rb +398 -0
- data/lib/protege/inference/provider/request.rb +110 -0
- data/lib/protege/inference/provider/response.rb +50 -0
- data/lib/protege/inference/provider/tool_call.rb +52 -0
- data/lib/protege/inference.rb +121 -0
- data/lib/protege/loop/schedule.rb +66 -0
- data/lib/protege/loop/scheduler.rb +42 -0
- data/lib/protege/loop.rb +37 -0
- data/lib/protege/orchestrator/context.rb +65 -0
- data/lib/protege/orchestrator/harness.rb +305 -0
- data/lib/protege/orchestrator/reply_context.rb +21 -0
- data/lib/protege/orchestrator/reply_harness.rb +68 -0
- data/lib/protege/orchestrator/resolver_chain.rb +93 -0
- data/lib/protege/orchestrator/responsibility_context.rb +22 -0
- data/lib/protege/orchestrator/responsibility_harness.rb +60 -0
- data/lib/protege/orchestrator.rb +37 -0
- data/lib/protege/subscribers/hook_dispatcher.rb +140 -0
- data/lib/protege/subscribers/tracing.rb +65 -0
- data/lib/protege/types/document_part.rb +13 -0
- data/lib/protege/types/image_part.rb +11 -0
- data/lib/protege/types/model_message.rb +40 -0
- data/lib/protege/types/result.rb +113 -0
- data/lib/protege/types/stored_file.rb +127 -0
- data/lib/protege/types/text_part.rb +8 -0
- data/lib/protege/types/tool_result.rb +16 -0
- data/lib/protege/version.rb +6 -0
- data/lib/protege.rb +47 -0
- data/lib/tasks/protege_tasks.rake +16 -0
- metadata +494 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Components
|
|
5
|
+
# Form-building UI elements shared across engine views.
|
|
6
|
+
#
|
|
7
|
+
# Split out of +ComponentsHelper+ so each module stays focused; +FormsHelper+
|
|
8
|
+
# owns the input styling constants and the field/select/checkbox builders that
|
|
9
|
+
# wrap Rails form helpers in Protege's design system.
|
|
10
|
+
#
|
|
11
|
+
# Colors use CSS custom properties (--surface, --border, --text, --text-faint)
|
|
12
|
+
# defined in tailwind/application.css so dark mode works via a single .dark
|
|
13
|
+
# class toggle.
|
|
14
|
+
module FormsHelper
|
|
15
|
+
# Tailwind classes applied to text inputs, selects, and textareas.
|
|
16
|
+
FORM_INPUT = 'block w-full rounded-sm px-2.5 py-1.5 text-sm shadow-sm ' \
|
|
17
|
+
'focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500'
|
|
18
|
+
# Tailwind classes applied to field labels.
|
|
19
|
+
FORM_LABEL = 'block text-sm font-medium mb-1'
|
|
20
|
+
# Tailwind classes applied to checkboxes.
|
|
21
|
+
FORM_CHECKBOX = 'h-4 w-4 rounded text-amber-600 focus:ring-amber-500'
|
|
22
|
+
|
|
23
|
+
# Build the inline style applied to form controls so they track the theme.
|
|
24
|
+
#
|
|
25
|
+
# @return [String] the CSS custom-property-backed style string
|
|
26
|
+
def form_input_style
|
|
27
|
+
'background: var(--surface); border: 1px solid var(--border); color: var(--text)'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Render a record's validation errors as a styled list, or nothing when valid.
|
|
31
|
+
#
|
|
32
|
+
# @param record [ActiveModel::Validations] the record whose errors to render
|
|
33
|
+
# @return [ActiveSupport::SafeBuffer] the error block, or an empty buffer
|
|
34
|
+
def form_errors(record)
|
|
35
|
+
return ''.html_safe unless record.errors.any?
|
|
36
|
+
|
|
37
|
+
tag.div(class: 'rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700') do
|
|
38
|
+
tag.ul(class: 'list-disc pl-4 space-y-1') do
|
|
39
|
+
safe_join(record.errors.full_messages.map { |message| tag.li(message) })
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Render a labelled text field with an optional hint, wired to a form builder.
|
|
45
|
+
#
|
|
46
|
+
# @param form [ActionView::Helpers::FormBuilder] the form builder
|
|
47
|
+
# @param field [Symbol] the attribute the field edits
|
|
48
|
+
# @param label [String, nil] the label text; defaults to the attribute's humanized name
|
|
49
|
+
# @param hint [String, nil] optional helper text shown beneath the field
|
|
50
|
+
# @param input_attrs [Hash] extra HTML attributes forwarded to the input
|
|
51
|
+
# @return [ActiveSupport::SafeBuffer] the field markup
|
|
52
|
+
def form_field(form, field, label: nil, hint: nil, **input_attrs)
|
|
53
|
+
tag.div do
|
|
54
|
+
form.label(field, label, class: FORM_LABEL) +
|
|
55
|
+
form.text_field(field, class: FORM_INPUT, style: form_input_style, **input_attrs) +
|
|
56
|
+
form_hint(hint)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Render a labelled select wired to a form builder.
|
|
61
|
+
#
|
|
62
|
+
# @param form [ActionView::Helpers::FormBuilder] the form builder
|
|
63
|
+
# @param field [Symbol] the attribute the select edits
|
|
64
|
+
# @param choices [Object] the option set passed to +FormBuilder#select+
|
|
65
|
+
# @param label [String, nil] the label text; defaults to the attribute's humanized name
|
|
66
|
+
# @param prompt [String, nil] optional blank-choice prompt
|
|
67
|
+
# @param selected [String, nil] the value to preselect — needed on a modelless form (e.g. a GET
|
|
68
|
+
# search form) where the builder has no object to read the current value from
|
|
69
|
+
# @param attrs [Hash] extra HTML attributes forwarded to the select
|
|
70
|
+
# @return [ActiveSupport::SafeBuffer] the select markup
|
|
71
|
+
def form_select(form, field, choices, label: nil, prompt: nil, selected: nil, **attrs)
|
|
72
|
+
tag.div do
|
|
73
|
+
form.label(field, label, class: FORM_LABEL) +
|
|
74
|
+
form.select(field, choices, { prompt:, selected: }, class: FORM_INPUT, style: form_input_style, **attrs)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Render the split prefix/domain email-address control for a persona.
|
|
79
|
+
#
|
|
80
|
+
# When domains exist, render a prefix text box, an +@+ separator, and a domain
|
|
81
|
+
# select preselected to the record's current domain; otherwise prompt the
|
|
82
|
+
# operator to add a domain first.
|
|
83
|
+
#
|
|
84
|
+
# @param record [Protege::Persona] the persona whose email address to edit
|
|
85
|
+
# @param domains [Enumerable<Protege::EmailDomain>] the selectable domains
|
|
86
|
+
# @return [ActiveSupport::SafeBuffer] the email-address control markup
|
|
87
|
+
def form_email_address(record, domains)
|
|
88
|
+
tag.div do
|
|
89
|
+
tag.label('Email address', class: FORM_LABEL) +
|
|
90
|
+
tag.div(class: 'flex items-center gap-2') do
|
|
91
|
+
domains.any? ? email_address_fields(record, domains) : email_address_empty
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Render a labelled textarea with an optional hint, wired to a form builder.
|
|
97
|
+
#
|
|
98
|
+
# @param form [ActionView::Helpers::FormBuilder] the form builder
|
|
99
|
+
# @param field [Symbol] the attribute the textarea edits
|
|
100
|
+
# @param label [String, nil] optional label text; omitted when nil
|
|
101
|
+
# @param hint [String, nil] optional helper text shown beneath the textarea
|
|
102
|
+
# @param fill [Boolean] when true, the wrapper and textarea flex to fill the height of a flex-column
|
|
103
|
+
# parent (e.g. a resizable reply composer) instead of using the default fixed-minimum, self-resizable size
|
|
104
|
+
# @param input_attrs [Hash] extra HTML attributes forwarded to the textarea
|
|
105
|
+
# @return [ActiveSupport::SafeBuffer] the textarea markup
|
|
106
|
+
def form_text_area(form, field, label: nil, hint: nil, fill: false, **input_attrs)
|
|
107
|
+
input_attrs[:rows] ||= 3
|
|
108
|
+
size_class = fill ? 'flex-1 min-h-0 resize-none' : 'resize-y min-h-[6rem]'
|
|
109
|
+
tag.div(class: fill ? 'flex min-h-0 flex-1 flex-col' : nil) do
|
|
110
|
+
(label ? form.label(field, label, class: FORM_LABEL) : ''.html_safe) +
|
|
111
|
+
form.text_area(field, class: "#{FORM_INPUT} font-mono #{size_class}",
|
|
112
|
+
style: form_input_style, **input_attrs) +
|
|
113
|
+
form_hint(hint)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Render a labelled checkbox wired to a form builder.
|
|
118
|
+
#
|
|
119
|
+
# @param form [ActionView::Helpers::FormBuilder] the form builder
|
|
120
|
+
# @param field [Symbol] the attribute the checkbox edits
|
|
121
|
+
# @param label [String, nil] the label text; defaults to the attribute's humanized name
|
|
122
|
+
# @return [ActiveSupport::SafeBuffer] the checkbox markup
|
|
123
|
+
def form_checkbox(form, field, label: nil)
|
|
124
|
+
tag.div(class: 'flex items-center gap-3') do
|
|
125
|
+
form.check_box(field, class: FORM_CHECKBOX, style: 'border: 1px solid var(--border)') +
|
|
126
|
+
form.label(field, label, class: 'text-sm font-medium')
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Render a labelled checklist — one checkbox per item, backed by an array attribute, wired to a
|
|
131
|
+
# form builder. Rails' +collection_check_boxes+ emits the hidden blank field for us, so unchecking
|
|
132
|
+
# every box submits an empty array (clearing the attribute) instead of omitting it.
|
|
133
|
+
#
|
|
134
|
+
# @param form [ActionView::Helpers::FormBuilder] the form builder
|
|
135
|
+
# @param field [Symbol] the array attribute the checklist edits
|
|
136
|
+
# @param collection [Enumerable] the items, one checkbox each
|
|
137
|
+
# @param label [String, nil] the group label; omitted when nil
|
|
138
|
+
# @param hint [String, nil] optional helper text shown beneath the label
|
|
139
|
+
# @param value [Symbol, Proc] maps an item to its submitted value (default: +:to_s+)
|
|
140
|
+
# @param text [Symbol, Proc] maps an item to its checkbox label (default: +:to_s+)
|
|
141
|
+
# @return [ActiveSupport::SafeBuffer] the checklist markup
|
|
142
|
+
def form_checklist(form, field, collection, label: nil, hint: nil, value: :to_s, text: :to_s)
|
|
143
|
+
tag.div do
|
|
144
|
+
(label ? tag.label(label, class: FORM_LABEL) : ''.html_safe) +
|
|
145
|
+
form_hint(hint) +
|
|
146
|
+
tag.div(class: 'space-y-2 pt-1') do
|
|
147
|
+
form.collection_check_boxes(field, collection, value, text) do |b|
|
|
148
|
+
b.label(class: 'flex items-center gap-3 text-sm') do
|
|
149
|
+
b.check_box(class: FORM_CHECKBOX, style: 'border: 1px solid var(--border)') + b.text
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
# Render the muted hint paragraph shown beneath a field, or nothing.
|
|
159
|
+
#
|
|
160
|
+
# @param hint [String, nil] the hint text
|
|
161
|
+
# @return [ActiveSupport::SafeBuffer] the hint paragraph, or an empty buffer
|
|
162
|
+
def form_hint(hint)
|
|
163
|
+
return ''.html_safe unless hint
|
|
164
|
+
|
|
165
|
+
tag.p(hint, class: 'mt-1 text-xs', style: 'color: var(--text-faint)')
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Render the prefix input, +@+ separator, and domain select for an address.
|
|
169
|
+
#
|
|
170
|
+
# @param record [Protege::Persona] the persona whose address to edit
|
|
171
|
+
# @param domains [Enumerable<Protege::EmailDomain>] the selectable domains
|
|
172
|
+
# @return [ActiveSupport::SafeBuffer] the prefix/separator/select markup
|
|
173
|
+
def email_address_fields(record, domains)
|
|
174
|
+
input_base = FORM_INPUT.sub('w-full ', '')
|
|
175
|
+
|
|
176
|
+
email_address_prefix(record, input_base) +
|
|
177
|
+
tag.span('@', class: 'text-sm select-none', style: 'color: var(--text-faint)') +
|
|
178
|
+
email_address_domain_select(record, domains, input_base)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Render the local-part text input, seeded with the record's current prefix.
|
|
182
|
+
#
|
|
183
|
+
# @param record [Protege::Persona] the persona whose prefix to seed
|
|
184
|
+
# @param input_base [String] the shared input classes without the +w-full+ utility
|
|
185
|
+
# @return [ActiveSupport::SafeBuffer] the prefix input markup
|
|
186
|
+
def email_address_prefix(record, input_base)
|
|
187
|
+
tag.input(
|
|
188
|
+
type: 'text',
|
|
189
|
+
name: 'persona[email_prefix]',
|
|
190
|
+
value: record.email_address&.split('@')&.first,
|
|
191
|
+
placeholder: 'alice',
|
|
192
|
+
class: "block w-40 #{input_base}",
|
|
193
|
+
style: form_input_style
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Render the domain select, preselecting the record's current domain.
|
|
198
|
+
#
|
|
199
|
+
# @param record [Protege::Persona] the persona whose domain to preselect
|
|
200
|
+
# @param domains [Enumerable<Protege::EmailDomain>] the selectable domains
|
|
201
|
+
# @param input_base [String] the shared input classes without the +w-full+ utility
|
|
202
|
+
# @return [ActiveSupport::SafeBuffer] the domain select markup
|
|
203
|
+
def email_address_domain_select(record, domains, input_base)
|
|
204
|
+
options = safe_join(domains.map do |domain|
|
|
205
|
+
tag.option(domain.domain, value: domain.domain,
|
|
206
|
+
selected: record.email_address&.end_with?("@#{domain.domain}"))
|
|
207
|
+
end)
|
|
208
|
+
|
|
209
|
+
tag.select(options, name: 'persona[email_domain]', class: "block flex-1 #{input_base}",
|
|
210
|
+
style: form_input_style)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Render the placeholder shown when no email domains are configured.
|
|
214
|
+
#
|
|
215
|
+
# @return [ActiveSupport::SafeBuffer] the "add a domain" prompt
|
|
216
|
+
def email_address_empty
|
|
217
|
+
tag.span(class: 'text-xs italic', style: 'color: var(--text-faint)') do
|
|
218
|
+
'No domains configured — '.html_safe + ui_link('add one', new_email_domain_path)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Components
|
|
5
|
+
# Rendering for the keyboard-shortcuts overlay (see +shared/_hotkeys+ and +keys.js+): the header "?"
|
|
6
|
+
# button that opens it, plus the section dividers, rows, and key caps its static, hand-authored
|
|
7
|
+
# template is composed from. keys.js stays purely behavioral — it only toggles the dialog and
|
|
8
|
+
# activates +[data-hotkey]+ elements — so the overlay's content is built here from plain markup.
|
|
9
|
+
module HotkeysHelper
|
|
10
|
+
# Render the keycap-styled "?" button that opens the overlay. Looks like a key (matching the caps
|
|
11
|
+
# below) and calls +toggleHotkeyHelp()+ (exposed by keys.js) — the same open/close path as "?".
|
|
12
|
+
#
|
|
13
|
+
# @return [ActiveSupport::SafeBuffer] the button markup
|
|
14
|
+
def hotkey_help_button
|
|
15
|
+
cap_style = 'background: var(--surface-subtle); border: 1px solid var(--border); color: var(--text-muted)'
|
|
16
|
+
tag.button('?',
|
|
17
|
+
type: 'button',
|
|
18
|
+
'aria-label': 'Keyboard shortcuts',
|
|
19
|
+
title: 'Keyboard shortcuts',
|
|
20
|
+
class: 'inline-flex px-1 h-5 w-5 items-center justify-center rounded text-xs font-mono ' \
|
|
21
|
+
'cursor-pointer transition-colors',
|
|
22
|
+
style: cap_style,
|
|
23
|
+
onclick: 'toggleHotkeyHelp()')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Render a titled section divider — the label followed by a rule filling the rest of the row
|
|
27
|
+
# ("Go to ————").
|
|
28
|
+
#
|
|
29
|
+
# @param title [String] the section label
|
|
30
|
+
# @return [ActiveSupport::SafeBuffer] the divider markup
|
|
31
|
+
def hotkey_section(title)
|
|
32
|
+
tag.div(class: 'flex items-center gap-2 pt-3 first:pt-0 text-xs font-semibold uppercase tracking-wider',
|
|
33
|
+
style: 'color: var(--text-faint)') do
|
|
34
|
+
tag.span(title) + tag.span('', class: 'flex-1', style: 'border-top: 1px solid var(--border)')
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Render one shortcut row: a label on the left, its key caps on the right.
|
|
39
|
+
#
|
|
40
|
+
# @param label [String] the shortcut's description
|
|
41
|
+
# @param keys [Array<String>] the keys to show as caps, in order (e.g. "g", "1")
|
|
42
|
+
# @return [ActiveSupport::SafeBuffer] the row markup
|
|
43
|
+
def hotkey_row(label, *keys)
|
|
44
|
+
tag.div(class: 'flex items-center justify-between gap-6 py-1') do
|
|
45
|
+
tag.span(label) +
|
|
46
|
+
tag.span(class: 'flex items-center gap-1') { safe_join(keys.map { |key| hotkey_cap(key) }) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Render a single keycap (+<kbd>+), styled to match the header's "?" button.
|
|
51
|
+
#
|
|
52
|
+
# @param key [String] the key label
|
|
53
|
+
# @return [ActiveSupport::SafeBuffer] the keycap markup
|
|
54
|
+
def hotkey_cap(key)
|
|
55
|
+
tag.kbd(key, class: 'rounded px-1.5 py-0.5 text-xs font-mono',
|
|
56
|
+
style: 'background: var(--surface-subtle); border: 1px solid var(--border)')
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Components
|
|
5
|
+
# Inline SVG icons — a small registry of single-path, stroked glyphs (Lucide geometry). Each renders
|
|
6
|
+
# as +currentColor+ so it inherits the surrounding text color and theme. Add an entry to +ICONS+ to
|
|
7
|
+
# introduce a new glyph; render one with +icon(:name)+.
|
|
8
|
+
module IconsHelper
|
|
9
|
+
# Icon key => the +d+ attribute of its single +<path>+.
|
|
10
|
+
ICONS = {
|
|
11
|
+
paperclip: 'M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66' \
|
|
12
|
+
'l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48'
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
# Render a named icon as an inline SVG that inherits the current text color.
|
|
16
|
+
#
|
|
17
|
+
# @param name [Symbol] the icon key (see +ICONS+)
|
|
18
|
+
# @param size [Integer] the pixel width and height
|
|
19
|
+
# @param attrs [Hash] extra attributes merged onto the +<svg>+ (e.g. +class:+)
|
|
20
|
+
# @return [ActiveSupport::SafeBuffer] the SVG markup
|
|
21
|
+
# @raise [ArgumentError] when +name+ is not a registered icon
|
|
22
|
+
def icon(name, size: 16, **attrs)
|
|
23
|
+
path = ICONS.fetch(name) { raise ArgumentError, "unknown icon #{name.inspect}" }
|
|
24
|
+
|
|
25
|
+
tag.svg(
|
|
26
|
+
tag.path(d: path),
|
|
27
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
28
|
+
width: size,
|
|
29
|
+
height: size,
|
|
30
|
+
viewBox: '0 0 24 24',
|
|
31
|
+
fill: 'none',
|
|
32
|
+
stroke: 'currentColor',
|
|
33
|
+
'stroke-width': 2,
|
|
34
|
+
'stroke-linecap': 'round',
|
|
35
|
+
'stroke-linejoin': 'round',
|
|
36
|
+
**attrs
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Components
|
|
5
|
+
# View helpers for the thread **introspection panel** — the collapsible right sidebar in the console
|
|
6
|
+
# that surfaces the agent's reasoning and tool use for the open thread. Mirrors +LayoutHelper+'s
|
|
7
|
+
# sidebar builders: small composable pieces (panel shell, section, tool card, toggle) so the view
|
|
8
|
+
# stays declarative and the panel's markup lives in one place as it grows.
|
|
9
|
+
module IntrospectionHelper
|
|
10
|
+
# DOM id of the panel +<aside>+ — the toggle shows/hides this element.
|
|
11
|
+
INTROSPECTION_PANEL_ID = 'thread-introspection'
|
|
12
|
+
|
|
13
|
+
# Render the panel shell: a full-height right +<aside>+ (mirroring the left inbox aside) with a fixed
|
|
14
|
+
# header and a scrollable body that yields its content (the activity feed).
|
|
15
|
+
#
|
|
16
|
+
# The user can resize its width by dragging the left-edge handle (the general +resize_handle+ paired
|
|
17
|
+
# with the engine's self-contained +resize.js+), bounded by min/max and remembered across page loads.
|
|
18
|
+
# Collapsing (open/close) is handled by +introspection_toggle+ in the thread header.
|
|
19
|
+
#
|
|
20
|
+
# @yield the panel body (e.g. +introspection_feed+)
|
|
21
|
+
# @return [ActiveSupport::SafeBuffer] the panel markup
|
|
22
|
+
def introspection_panel(&block)
|
|
23
|
+
class_names = 'relative flex w-80 flex-shrink-0 flex-col overflow-hidden'
|
|
24
|
+
|
|
25
|
+
data = {
|
|
26
|
+
resize: '',
|
|
27
|
+
resize_edge: 'left',
|
|
28
|
+
resize_min: 240,
|
|
29
|
+
resize_max: 720,
|
|
30
|
+
resize_key: 'protege:introspection-width'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
tag.aside(id: INTROSPECTION_PANEL_ID, class: class_names, data:) do
|
|
34
|
+
resize_handle(edge: :left) +
|
|
35
|
+
introspection_header +
|
|
36
|
+
tag.div(class: 'flex-1 overflow-y-auto px-4 py-3', &block)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The toggle button (placed in the thread header) that shows/hides the panel.
|
|
41
|
+
#
|
|
42
|
+
# @return [ActiveSupport::SafeBuffer] the toggle button markup
|
|
43
|
+
def introspection_toggle
|
|
44
|
+
button('Introspection',
|
|
45
|
+
variant: :secondary,
|
|
46
|
+
type: 'button',
|
|
47
|
+
onclick: "document.getElementById('#{INTROSPECTION_PANEL_ID}').classList.toggle('hidden')")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The live activity feed container — the single scrollable target the +IntrospectionBroadcaster+
|
|
51
|
+
# appends reasoning tokens and tool-call cards into, interleaved chronologically. Live-only: the
|
|
52
|
+
# +introspection-empty+ placeholder (see tailwind/application.css) shows until the next run streams in.
|
|
53
|
+
#
|
|
54
|
+
# Marked +data-turbo-permanent+ so the accumulated feed survives Turbo Drive navigations (the id is
|
|
55
|
+
# per-thread, so it is only preserved within the same thread). A hard page reload still starts it
|
|
56
|
+
# empty — permanence spans Drive visits only — keeping the feed live-only.
|
|
57
|
+
#
|
|
58
|
+
# @param thread [Protege::EmailThread] the open thread
|
|
59
|
+
# @return [ActiveSupport::SafeBuffer] the feed container markup
|
|
60
|
+
def introspection_feed(thread)
|
|
61
|
+
tag.div(id: dom_id(thread, :activity),
|
|
62
|
+
class: 'introspection-empty text-xs font-mono leading-relaxed',
|
|
63
|
+
style: 'color: var(--text-muted)',
|
|
64
|
+
data: { empty: 'Waiting for activity…', turbo_permanent: '' })
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A reasoning block — one paragraph of the model's streamed reasoning. The +IntrospectionBroadcaster+
|
|
68
|
+
# appends a fresh block after each tool call and streams that turn's tokens into it (by +id+), so
|
|
69
|
+
# reasoning is grouped into spaced, whitespace-preserving paragraphs rather than loose text nodes.
|
|
70
|
+
#
|
|
71
|
+
# @param id [String] the block's DOM id (the target the run's tokens append into)
|
|
72
|
+
# @param content [String, nil] the block's initial token text
|
|
73
|
+
# @return [ActiveSupport::SafeBuffer] the reasoning block markup
|
|
74
|
+
def introspection_reasoning(id:, content: nil)
|
|
75
|
+
tag.div(content, id:, class: 'my-3 whitespace-pre-wrap')
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A single tool-call card: a status glyph, the tool name, an optional one-line args summary, and an
|
|
79
|
+
# optional response preview (done) or error (failed). Streamed into the panel as a +:pending+ card
|
|
80
|
+
# the instant the model requests the call, then replaced in place with the outcome — so pass a stable
|
|
81
|
+
# +id:+ (the card's DOM id) that the replace can target.
|
|
82
|
+
#
|
|
83
|
+
# @param name [String] the tool name
|
|
84
|
+
# @param args [String, nil] a one-line argument summary
|
|
85
|
+
# @param status [Symbol] +:pending+, +:done+, or +:failed+
|
|
86
|
+
# @param preview [String, nil] a response preview or error string
|
|
87
|
+
# @param id [String, nil] the card's DOM id (so a pending card can be replaced by its outcome)
|
|
88
|
+
# @return [ActiveSupport::SafeBuffer] the card markup
|
|
89
|
+
def introspection_tool_card(name:, args: nil, status: :done, preview: nil, id: nil)
|
|
90
|
+
glyph, color, pulsing = tool_card_glyph(status)
|
|
91
|
+
tag.div(id:, class: 'my-3 pl-2 text-xs font-mono', style: 'border-left: 2px solid var(--border)') do
|
|
92
|
+
tag.div(class: 'flex items-center gap-2') do
|
|
93
|
+
tag.span(glyph, class: pulsing ? 'animate-pulse' : '', style: "color: #{color}") +
|
|
94
|
+
tag.span(name, class: 'font-medium', style: 'color: var(--text)')
|
|
95
|
+
end +
|
|
96
|
+
introspection_card_line(args, color: 'var(--text-muted)') +
|
|
97
|
+
introspection_card_line(preview.present? ? "→ #{preview}" : nil, color: 'var(--text-faint)')
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A run-lifecycle marker in the activity feed: a pulsing "Processing…" line while the run is in
|
|
102
|
+
# flight, or a red error when it fails. Appended on run start; on success it is removed, on failure
|
|
103
|
+
# replaced in place (by its +id+) with the error — so each run reads as processing → reasoning/tool
|
|
104
|
+
# use, then repeats for the next reply.
|
|
105
|
+
#
|
|
106
|
+
# @param id [String] the marker's DOM id (stable across the run so the outcome replaces it)
|
|
107
|
+
# @param status [Symbol] +:processing+ or +:failed+
|
|
108
|
+
# @param detail [String, nil] the error message, shown when +:failed+
|
|
109
|
+
# @return [ActiveSupport::SafeBuffer] the marker markup
|
|
110
|
+
def introspection_run_marker(id:, status: :processing, detail: nil)
|
|
111
|
+
if status == :failed
|
|
112
|
+
tag.div(id:, class: 'my-3 text-xs font-mono', style: 'color: #ef4444') do
|
|
113
|
+
tag.div('✗ Run failed', class: 'font-medium') +
|
|
114
|
+
introspection_card_line(detail, color: '#ef4444')
|
|
115
|
+
end
|
|
116
|
+
else
|
|
117
|
+
tag.div(id:, class: 'my-3 flex items-center gap-2 text-xs animate-pulse',
|
|
118
|
+
style: 'color: var(--text-faint)') do
|
|
119
|
+
tag.span('•') + tag.span('Processing…')
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
# The glyph, color, and pulse for a tool card's status.
|
|
127
|
+
#
|
|
128
|
+
# @param status [Symbol] +:pending+, +:done+, or +:failed+
|
|
129
|
+
# @return [Array(String, String, Boolean)] the glyph, its CSS color, and whether it pulses
|
|
130
|
+
def tool_card_glyph(status)
|
|
131
|
+
case status
|
|
132
|
+
when :failed then ['✗', '#ef4444', false]
|
|
133
|
+
when :pending then ['•', 'var(--text-faint)', true]
|
|
134
|
+
else ['✓', 'var(--text-faint)', false]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The panel's fixed header row (matches +sidebar_header+'s styling).
|
|
139
|
+
#
|
|
140
|
+
# @return [ActiveSupport::SafeBuffer] the header markup
|
|
141
|
+
def introspection_header
|
|
142
|
+
tag.div(class: 'flex h-10 flex-shrink-0 items-center px-4',
|
|
143
|
+
style: 'border-bottom: 1px solid var(--border)') do
|
|
144
|
+
tag.span('Introspection',
|
|
145
|
+
class: 'text-xs font-semibold uppercase tracking-wider',
|
|
146
|
+
style: 'color: var(--text-faint)')
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# One wrapping text line inside a tool card, or an empty buffer when blank.
|
|
151
|
+
#
|
|
152
|
+
# @param text [String, nil] the line text
|
|
153
|
+
# @param color [String] the CSS color value
|
|
154
|
+
# @return [ActiveSupport::SafeBuffer] the line markup, or an empty buffer
|
|
155
|
+
def introspection_card_line(text, color:)
|
|
156
|
+
return ''.html_safe if text.blank?
|
|
157
|
+
|
|
158
|
+
tag.div(text, class: 'mt-1 whitespace-pre-wrap break-all', style: "color: #{color}")
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Components
|
|
5
|
+
# Page-scaffolding UI elements shared across engine views — the app shell (body,
|
|
6
|
+
# header, content region, main column), page headers and containers, navigation,
|
|
7
|
+
# and the theme toggle. A flat helper module among peers, all aggregated by
|
|
8
|
+
# +Protege::ApplicationHelper+ and mixed into the view together.
|
|
9
|
+
#
|
|
10
|
+
# Colors use CSS custom properties (--surface, --border, --text, --text-muted,
|
|
11
|
+
# --text-faint) defined in tailwind/application.css so dark mode works via a
|
|
12
|
+
# single .dark class toggle.
|
|
13
|
+
module LayoutHelper
|
|
14
|
+
# ── App shell ────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
# Render the app +<body>+ — a full-height flex column holding the fixed header and the content region
|
|
17
|
+
# (so the layout template carries no structural classes of its own). The block is the main content.
|
|
18
|
+
#
|
|
19
|
+
# @yield the main content
|
|
20
|
+
# @return [ActiveSupport::SafeBuffer] the body markup
|
|
21
|
+
def app_body(&main)
|
|
22
|
+
tag.body(class: 'h-full flex flex-col antialiased') { app_header + app_content(&main) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Render the app content region below the header — the full-height flex row holding the optional
|
|
26
|
+
# sidebar and the main column. The sidebar is drawn from the +:sidebar+ content buffer (a view's
|
|
27
|
+
# +content_for :sidebar+), so pages opt in just by defining it; the block is the main content.
|
|
28
|
+
#
|
|
29
|
+
# @yield the main content
|
|
30
|
+
# @return [ActiveSupport::SafeBuffer] the content-region markup
|
|
31
|
+
def app_content(&main)
|
|
32
|
+
tag.div(class: 'flex min-h-0 flex-1') { app_sidebar + app_main(&main) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Render the app's fixed top header (the shared header partial).
|
|
38
|
+
#
|
|
39
|
+
# @return [ActiveSupport::SafeBuffer] the header markup
|
|
40
|
+
def app_header
|
|
41
|
+
render('protege/shared/header')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Render the docked sidebar from the +:sidebar+ content buffer, or nothing when a page defines none.
|
|
45
|
+
#
|
|
46
|
+
# @return [ActiveSupport::SafeBuffer] the sidebar shell, or an empty buffer
|
|
47
|
+
def app_sidebar
|
|
48
|
+
sidebar = content_for(:sidebar)
|
|
49
|
+
return ''.html_safe if sidebar.blank?
|
|
50
|
+
|
|
51
|
+
sidebar_container { sidebar }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Render the main content column that fills the space beside the sidebar.
|
|
55
|
+
#
|
|
56
|
+
# @yield the main content
|
|
57
|
+
# @return [ActiveSupport::SafeBuffer] the +<main>+ markup
|
|
58
|
+
def app_main(&)
|
|
59
|
+
tag.main(class: 'flex min-w-0 flex-1 flex-col overflow-hidden', &)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# ── Page layout ──────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
# Render a page header with a title, optional description, and optional actions.
|
|
65
|
+
#
|
|
66
|
+
# @param title [String] the page title
|
|
67
|
+
# @param description [String, nil] optional subtitle shown beneath the title
|
|
68
|
+
# @yield optional actions rendered to the right of the title
|
|
69
|
+
# @return [ActiveSupport::SafeBuffer] the header markup
|
|
70
|
+
def page_header(title, description = nil, &block)
|
|
71
|
+
tag.div(class: 'flex items-center justify-between mb-6') do
|
|
72
|
+
left = tag.div do
|
|
73
|
+
heading_1(title) +
|
|
74
|
+
(if description
|
|
75
|
+
tag.p(description, class: 'mt-1 text-sm',
|
|
76
|
+
style: 'color: var(--text-muted)')
|
|
77
|
+
else
|
|
78
|
+
''.html_safe
|
|
79
|
+
end)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
right = block ? capture(&block) : ''.html_safe
|
|
83
|
+
|
|
84
|
+
left + right
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Render the light/dark theme toggle switch. Carries the +g t+ hotkey directly (keys.js clicks it),
|
|
89
|
+
# so the shortcut lives on the real control rather than a hidden duplicate.
|
|
90
|
+
#
|
|
91
|
+
# @return [ActiveSupport::SafeBuffer] the toggle markup
|
|
92
|
+
def theme_toggle
|
|
93
|
+
tag.button(type: 'button',
|
|
94
|
+
class: 'relative inline-flex h-5 w-9 items-center rounded-full cursor-pointer transition-colors',
|
|
95
|
+
style: 'background: var(--surface-subtle); border: 1px solid var(--border)',
|
|
96
|
+
data: { hotkey: 'g t' },
|
|
97
|
+
onclick: 'toggleTheme()') do
|
|
98
|
+
dot_position = dark_mode? ? 'translate-x-4' : 'translate-x-1'
|
|
99
|
+
dot_class = 'toggle-dot inline-block h-3 w-3 rounded-full transition-transform bg-amber-500'
|
|
100
|
+
tag.span('', class: "#{dot_class} #{dot_position}")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Report whether dark mode is the server-rendered default.
|
|
105
|
+
#
|
|
106
|
+
# Theme state lives client-side; this always returns false so the initial
|
|
107
|
+
# render is light and JS swaps the class as needed.
|
|
108
|
+
#
|
|
109
|
+
# @return [Boolean] always false
|
|
110
|
+
def dark_mode?
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Render the scrollable, centered page content wrapper.
|
|
115
|
+
#
|
|
116
|
+
# @yield the page body content
|
|
117
|
+
# @return [ActiveSupport::SafeBuffer] the container markup
|
|
118
|
+
def page_container(&block)
|
|
119
|
+
tag.div(class: 'overflow-y-auto flex-1') do
|
|
120
|
+
tag.div(class: 'p-6 pb-16 max-w-4xl mx-auto', &block)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Render a centered empty-state container.
|
|
125
|
+
#
|
|
126
|
+
# @yield the empty-state message
|
|
127
|
+
# @return [ActiveSupport::SafeBuffer] the empty-state markup
|
|
128
|
+
def page_empty(&)
|
|
129
|
+
tag.div(class: 'flex h-full items-center justify-center text-sm', style: 'color: var(--text-faint)', &)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Render a card summarizing a topic with an optional "Learn more" link.
|
|
133
|
+
#
|
|
134
|
+
# @param title [String] the card title
|
|
135
|
+
# @param description [String] the card body text
|
|
136
|
+
# @param href [String, nil] optional link target for "Learn more"
|
|
137
|
+
# @return [ActiveSupport::SafeBuffer] the card markup
|
|
138
|
+
def grid_card(title, description, href: nil)
|
|
139
|
+
tag.div(class: 'rounded-xl p-5',
|
|
140
|
+
style: 'background: var(--surface); border: 1px solid var(--border)') do
|
|
141
|
+
tag.div(class: 'mb-1') { heading_3(title) } +
|
|
142
|
+
tag.p(description, class: 'text-xs leading-relaxed') +
|
|
143
|
+
(href ? tag.div(class: 'mt-3') { ui_link('Learn more', href, target: '_blank') } : ''.html_safe)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Render a responsive grid wrapper with one, two, or three columns.
|
|
148
|
+
#
|
|
149
|
+
# @param cols [Integer] the column count (1, 2, or 3)
|
|
150
|
+
# @yield the grid items
|
|
151
|
+
# @return [ActiveSupport::SafeBuffer] the grid markup
|
|
152
|
+
def grid_container(cols: 2, &)
|
|
153
|
+
col_class = { 1 => 'grid-cols-1', 2 => 'grid-cols-2', 3 => 'grid-cols-3' }[cols]
|
|
154
|
+
tag.div(class: "grid #{col_class} gap-4", &)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# A drag handle laid over a resizable container's edge. The +resize-handle+ class draws the divider
|
|
158
|
+
# line and its hover affordance (see tailwind/application.css); this element is the invisible grab
|
|
159
|
+
# strip +resize.js+ listens on. The container itself carries the +data-resize*+ attributes.
|
|
160
|
+
#
|
|
161
|
+
# @param edge [Symbol] which edge the handle sits on — +:left+ (default), +:right+, or +:top+
|
|
162
|
+
# @return [ActiveSupport::SafeBuffer] the handle markup
|
|
163
|
+
def resize_handle(edge: :left)
|
|
164
|
+
variant = { right: ' resize-handle--right', top: ' resize-handle--top' }[edge].to_s
|
|
165
|
+
tag.div('', class: "resize-handle#{variant}", data: { resize_handle: '' })
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|