glancer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +96 -0
  3. data/.rubocop.yml +54 -0
  4. data/CHANGELOG.md +88 -0
  5. data/CLAUDE.md +115 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/README.md +354 -0
  8. data/app/assets/config/glancer_manifest.js +1 -0
  9. data/app/assets/javascripts/glancer/application.js +15 -0
  10. data/app/assets/javascripts/glancer/controllers/chat_controller.js +101 -0
  11. data/app/assets/javascripts/glancer/controllers/message_controller.js +1052 -0
  12. data/app/assets/javascripts/glancer/controllers/toast_controller.js +63 -0
  13. data/app/assets/stylesheets/glancer/application.css +350 -0
  14. data/app/assets/stylesheets/glancer/code-blocks.css +6 -0
  15. data/app/assets/stylesheets/glancer/list.css +31 -0
  16. data/app/assets/stylesheets/glancer/scrollbar.css +16 -0
  17. data/app/assets/stylesheets/glancer/table.css +97 -0
  18. data/app/controllers/glancer/application_controller.rb +33 -0
  19. data/app/controllers/glancer/chats_controller.rb +49 -0
  20. data/app/controllers/glancer/messages_controller.rb +144 -0
  21. data/app/controllers/glancer/schema_controller.rb +29 -0
  22. data/app/controllers/glancer/settings_controller.rb +23 -0
  23. data/app/helpers/glancer/application_helper.rb +17 -0
  24. data/app/jobs/glancer/application_job.rb +6 -0
  25. data/app/jobs/glancer/process_message_job.rb +38 -0
  26. data/app/models/glancer/audit.rb +12 -0
  27. data/app/models/glancer/chat.rb +8 -0
  28. data/app/models/glancer/code_version.rb +12 -0
  29. data/app/models/glancer/embedding.rb +6 -0
  30. data/app/models/glancer/message.rb +25 -0
  31. data/app/models/glancer/setting.rb +23 -0
  32. data/app/models/glancer/sql_version.rb +6 -0
  33. data/app/views/glancer/_data/_importmap.json.erb +7 -0
  34. data/app/views/glancer/chats/_chat_sidebar.html.erb +2 -0
  35. data/app/views/glancer/chats/_show.html.erb +52 -0
  36. data/app/views/glancer/chats/_sidebar_chat_list.html.erb +30 -0
  37. data/app/views/glancer/chats/index.html.erb +10 -0
  38. data/app/views/glancer/chats/show.html.erb +1 -0
  39. data/app/views/glancer/messages/_data_table.html.erb +268 -0
  40. data/app/views/glancer/messages/_execution_error.html.erb +26 -0
  41. data/app/views/glancer/messages/_form.html.erb +93 -0
  42. data/app/views/glancer/messages/_message.html.erb +206 -0
  43. data/app/views/glancer/messages/_message_info.html.erb +176 -0
  44. data/app/views/glancer/messages/_temp_form.html.erb +100 -0
  45. data/app/views/glancer/messages/create.turbo_stream.erb +25 -0
  46. data/app/views/glancer/schema/show.html.erb +123 -0
  47. data/app/views/glancer/settings/show.html.erb +306 -0
  48. data/app/views/glancer/shared/_icons.html.erb +126 -0
  49. data/app/views/layouts/glancer/application.html.erb +234 -0
  50. data/config/locales/glancer.en.yml +90 -0
  51. data/config/locales/glancer.es.yml +90 -0
  52. data/config/locales/glancer.pt-BR.yml +90 -0
  53. data/config/routes.rb +20 -0
  54. data/db/migrate/20250629212642_create_glancer_audits.rb +19 -0
  55. data/db/migrate/20250629212643_create_glancer_chats.rb +10 -0
  56. data/db/migrate/20250629212645_create_glancer_embeddings.rb +17 -0
  57. data/db/migrate/20250629212647_create_glancer_messages.rb +29 -0
  58. data/db/migrate/20260513204129_add_user_edited_sql_to_glancer_messages.rb +11 -0
  59. data/db/migrate/20260513210647_create_glancer_sql_versions.rb +18 -0
  60. data/db/migrate/20260513210648_add_message_id_to_glancer_audits.rb +8 -0
  61. data/db/migrate/20260513220000_create_glancer_settings.rb +12 -0
  62. data/db/migrate/20260514083509_add_llm_model_to_glancer_messages.rb +9 -0
  63. data/db/migrate/20260523120000_rename_code_columns_in_glancer_messages.rb +8 -0
  64. data/db/migrate/20260523120001_rename_code_column_in_glancer_audits.rb +7 -0
  65. data/db/migrate/20260523120002_add_code_type_to_glancer_tables.rb +10 -0
  66. data/db/migrate/20260523120003_rename_glancer_sql_versions_to_code_versions.rb +8 -0
  67. data/db/migrate/20260523130000_add_enriched_question_to_glancer_messages.rb +7 -0
  68. data/db/migrate/20260524100000_add_status_to_glancer_messages.rb +9 -0
  69. data/lib/generators/glancer/install/install_generator.rb +74 -0
  70. data/lib/generators/glancer/install/templates/glancer.rb +227 -0
  71. data/lib/generators/glancer/install/templates/llm_context.glancer.md +51 -0
  72. data/lib/glancer/async_runner.rb +50 -0
  73. data/lib/glancer/chart_analyzer.rb +230 -0
  74. data/lib/glancer/configuration.rb +372 -0
  75. data/lib/glancer/engine.rb +90 -0
  76. data/lib/glancer/indexer/context_indexer.rb +58 -0
  77. data/lib/glancer/indexer/model_indexer.rb +64 -0
  78. data/lib/glancer/indexer/schema_indexer.rb +171 -0
  79. data/lib/glancer/indexer.rb +50 -0
  80. data/lib/glancer/retriever.rb +114 -0
  81. data/lib/glancer/utils/logger.rb +83 -0
  82. data/lib/glancer/utils/markdown_helper.rb +56 -0
  83. data/lib/glancer/utils/result_formatter.rb +25 -0
  84. data/lib/glancer/utils/table_stats.rb +18 -0
  85. data/lib/glancer/utils/transaction.rb +59 -0
  86. data/lib/glancer/version.rb +5 -0
  87. data/lib/glancer/workflow/ar_executor.rb +104 -0
  88. data/lib/glancer/workflow/ar_extractor.rb +25 -0
  89. data/lib/glancer/workflow/ar_prompt_builder.rb +64 -0
  90. data/lib/glancer/workflow/ar_sanitizer.rb +88 -0
  91. data/lib/glancer/workflow/builder.rb +129 -0
  92. data/lib/glancer/workflow/cache.rb +55 -0
  93. data/lib/glancer/workflow/executor.rb +72 -0
  94. data/lib/glancer/workflow/llm.rb +123 -0
  95. data/lib/glancer/workflow/prompt_builder.rb +143 -0
  96. data/lib/glancer/workflow/query_enricher.rb +117 -0
  97. data/lib/glancer/workflow/sql_extractor.rb +42 -0
  98. data/lib/glancer/workflow/sql_sanitizer.rb +42 -0
  99. data/lib/glancer/workflow/sql_validator.rb +67 -0
  100. data/lib/glancer/workflow.rb +158 -0
  101. data/lib/glancer.rb +50 -0
  102. data/lib/tasks/glancer/tailwind.rake +8 -0
  103. data/lib/tasks/glancer.rake +99 -0
  104. data/spec/glancer_spec.rb +62 -0
  105. data/spec/lib/glancer/async_runner_spec.rb +133 -0
  106. data/spec/lib/glancer/chart_analyzer_spec.rb +296 -0
  107. data/spec/lib/glancer/configuration_spec.rb +858 -0
  108. data/spec/lib/glancer/engine_spec.rb +209 -0
  109. data/spec/lib/glancer/indexer/context_indexer_spec.rb +96 -0
  110. data/spec/lib/glancer/indexer/model_indexer_spec.rb +103 -0
  111. data/spec/lib/glancer/indexer/schema_indexer_spec.rb +382 -0
  112. data/spec/lib/glancer/indexer_spec.rb +95 -0
  113. data/spec/lib/glancer/retriever_spec.rb +179 -0
  114. data/spec/lib/glancer/utils/logger_spec.rb +85 -0
  115. data/spec/lib/glancer/utils/markdown_helper_spec.rb +92 -0
  116. data/spec/lib/glancer/utils/result_formatter_spec.rb +73 -0
  117. data/spec/lib/glancer/utils/table_stats_spec.rb +34 -0
  118. data/spec/lib/glancer/utils/transaction_spec.rb +73 -0
  119. data/spec/lib/glancer/workflow/ar_executor_spec.rb +155 -0
  120. data/spec/lib/glancer/workflow/ar_extractor_spec.rb +50 -0
  121. data/spec/lib/glancer/workflow/ar_prompt_builder_spec.rb +79 -0
  122. data/spec/lib/glancer/workflow/ar_sanitizer_spec.rb +175 -0
  123. data/spec/lib/glancer/workflow/builder_spec.rb +204 -0
  124. data/spec/lib/glancer/workflow/cache_spec.rb +142 -0
  125. data/spec/lib/glancer/workflow/executor_spec.rb +149 -0
  126. data/spec/lib/glancer/workflow/llm_spec.rb +124 -0
  127. data/spec/lib/glancer/workflow/prompt_builder_spec.rb +196 -0
  128. data/spec/lib/glancer/workflow/query_enricher_spec.rb +184 -0
  129. data/spec/lib/glancer/workflow/sql_extractor_spec.rb +82 -0
  130. data/spec/lib/glancer/workflow/sql_sanitizer_spec.rb +98 -0
  131. data/spec/lib/glancer/workflow/sql_validator_spec.rb +166 -0
  132. data/spec/lib/glancer/workflow_spec.rb +308 -0
  133. data/spec/models/glancer/audit_spec.rb +82 -0
  134. data/spec/models/glancer/chat_spec.rb +60 -0
  135. data/spec/models/glancer/code_version_spec.rb +71 -0
  136. data/spec/models/glancer/embedding_spec.rb +73 -0
  137. data/spec/models/glancer/message_spec.rb +144 -0
  138. data/spec/models/glancer/setting_spec.rb +88 -0
  139. data/spec/models/glancer/sql_version_spec.rb +4 -0
  140. data/spec/spec_helper.rb +128 -0
  141. data/spec/support/schema.rb +55 -0
  142. metadata +255 -0
@@ -0,0 +1,176 @@
1
+ <div data-controller="message" id="message-info">
2
+ <% if local_assigns[:message_info] %>
3
+ <div class="fixed inset-0 z-50 flex justify-end"
4
+ id="message-info--area"
5
+ role="dialog"
6
+ aria-modal="true"
7
+ aria-label="Detalhes da mensagem">
8
+ <%# Backdrop %>
9
+ <div class="absolute inset-0 bg-black/40 backdrop-blur-sm"
10
+ data-action="click->message#closeMessageInfo"></div>
11
+
12
+ <%# Panel %>
13
+ <div id="message-info--content"
14
+ class="relative z-10 flex flex-col w-full max-w-xl bg-white dark:bg-gray-900 h-full shadow-2xl overflow-hidden transform translate-x-full transition-transform duration-200 ease-out"
15
+ data-action="click->message#stopPropagation">
16
+
17
+ <%# Panel header %>
18
+ <div class="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
19
+ <h2 class="text-sm font-semibold text-gray-900 dark:text-white"><%= t("glancer.info_panel.title") %></h2>
20
+ <button class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
21
+ data-action="click->message#closeMessageInfo"
22
+ aria-label="<%= t('glancer.info_panel.close') %>">
23
+ <svg class="w-4 h-4" aria-hidden="true"><use href="#icon-x"/></svg>
24
+ </button>
25
+ </div>
26
+
27
+ <%# Panel body %>
28
+ <div class="flex-1 overflow-y-auto px-5 py-5 space-y-6">
29
+ <%# User question %>
30
+ <% if message_info.user_message %>
31
+ <div>
32
+ <p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-2"><%= t("glancer.info_panel.user_question") %></p>
33
+ <div class="rounded-xl bg-gray-50 dark:bg-gray-800 px-4 py-3 text-sm text-gray-700 dark:text-gray-200">
34
+ <%= message_info.user_message.content %>
35
+ </div>
36
+ </div>
37
+ <% end %>
38
+
39
+ <%# Enriched question (only when enrichment is enabled and produced a different result) %>
40
+ <% if message_info.enriched_question.present? %>
41
+ <div>
42
+ <div class="flex items-center gap-2 mb-2">
43
+ <p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"><%= t("glancer.info_panel.enriched_question") %></p>
44
+ <span class="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-primary-50 dark:bg-primary-950 text-primary-600 dark:text-primary-400">enriched</span>
45
+ </div>
46
+ <div class="rounded-xl bg-gray-50 dark:bg-gray-800 px-4 py-3 text-sm text-gray-600 dark:text-gray-300 italic">
47
+ <%= message_info.enriched_question %>
48
+ </div>
49
+ </div>
50
+ <% end %>
51
+
52
+ <%# Generated code (SQL or ActiveRecord) %>
53
+ <% if message_info.code.present? %>
54
+ <% code_lang = message_info.code_type == "activerecord" ? "ruby" : "sql" %>
55
+ <% code_label = message_info.code_type == "activerecord" ? t("glancer.info_panel.current_code") : t("glancer.info_panel.current_sql") %>
56
+ <div>
57
+ <div class="flex items-center justify-between mb-2">
58
+ <p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"><%= code_label %></p>
59
+ <div class="flex items-center gap-2">
60
+ <% if message_info.user_edited_code? %>
61
+ <span class="text-[10px] font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950 px-2 py-0.5 rounded-full"><%= t("glancer.info_panel.user_edited") %></span>
62
+ <% end %>
63
+ <button class="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
64
+ data-action="click->message#copySql"
65
+ data-sql="<%= j message_info.code.strip %>"
66
+ aria-label="<%= t('glancer.messages.copy_sql') %>">
67
+ <svg class="w-3 h-3" aria-hidden="true"><use href="#icon-copy"/></svg>
68
+ </button>
69
+ </div>
70
+ </div>
71
+ <div class="rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700">
72
+ <pre class="!m-0 !rounded-none text-xs overflow-x-auto p-4"><code class="language-<%= code_lang %>"><%= message_info.code.strip %></code></pre>
73
+ </div>
74
+ </div>
75
+ <% end %>
76
+
77
+ <%# Code version history %>
78
+ <% versions = message_info.code_versions.order(created_at: :desc) %>
79
+ <% if versions.any? %>
80
+ <div>
81
+ <p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-2">
82
+ <%= t("glancer.info_panel.code_versions", count: versions.size) %>
83
+ </p>
84
+ <div class="space-y-2">
85
+ <% versions.each_with_index do |version, idx| %>
86
+ <details class="rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden" <%= idx == 0 ? 'open' : '' %>>
87
+ <summary class="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-gray-800 cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-gray-750 transition-colors">
88
+ <div class="flex items-center gap-2">
89
+ <svg class="w-3 h-3 text-gray-400 summary-arrow transition-transform duration-200" aria-hidden="true">
90
+ <use href="#icon-chevron-down"/>
91
+ </svg>
92
+ <span class="text-[11px] text-gray-600 dark:text-gray-300">
93
+ <%= version.created_at.strftime("%-d/%m/%Y %H:%M") %>
94
+ </span>
95
+ </div>
96
+ <div class="flex items-center gap-2">
97
+ <% if version.user_edited? %>
98
+ <span class="text-[10px] font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950 px-2 py-0.5 rounded-full">
99
+ <%= t("glancer.info_panel.user") %>
100
+ </span>
101
+ <% else %>
102
+ <span class="text-[10px] font-medium text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-950 px-2 py-0.5 rounded-full">
103
+ <%= t("glancer.info_panel.generated") %>
104
+ </span>
105
+ <% end %>
106
+ <button class="p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
107
+ data-action="click->message#copySql"
108
+ data-sql="<%= j version.code.strip %>"
109
+ aria-label="<%= t('glancer.messages.copy_sql') %>">
110
+ <svg class="w-3 h-3" aria-hidden="true"><use href="#icon-copy"/></svg>
111
+ </button>
112
+ </div>
113
+ </summary>
114
+ <div class="overflow-x-auto">
115
+ <% version_lang = message_info.code_type == "activerecord" ? "ruby" : "sql" %>
116
+ <pre class="!m-0 !rounded-none text-[11px] p-3"><code class="language-<%= version_lang %>"><%= version.code.strip %></code></pre>
117
+ </div>
118
+ </details>
119
+ <% end %>
120
+ </div>
121
+ </div>
122
+ <% end %>
123
+
124
+ <%# Execution history %>
125
+ <% executions = message_info.audits.order(executed_at: :desc) %>
126
+ <% if executions.any? %>
127
+ <div>
128
+ <p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-2">
129
+ <%= t("glancer.info_panel.execution_history", count: executions.size) %>
130
+ </p>
131
+ <div class="space-y-1.5">
132
+ <% executions.each do |audit| %>
133
+ <div class="flex items-start justify-between px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-100 dark:border-gray-700">
134
+ <div>
135
+ <p class="text-[11px] text-gray-600 dark:text-gray-300">
136
+ <%= audit.executed_at.strftime("%-d/%m/%Y às %H:%M:%S") %>
137
+ </p>
138
+ <p class="text-[10px] text-gray-400 dark:text-gray-500 font-mono mt-0.5">
139
+ run_id: <%= audit.run_id %>
140
+ </p>
141
+ </div>
142
+ <span class="text-[10px] font-medium text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded-full flex-shrink-0 ml-2">
143
+ <%= audit.adapter %>
144
+ </span>
145
+ </div>
146
+ <% end %>
147
+ </div>
148
+ </div>
149
+ <% end %>
150
+
151
+ <%# Status %>
152
+ <div>
153
+ <p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-2"><%= t("glancer.info_panel.status") %></p>
154
+ <% if message_info.successful? %>
155
+ <span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-400">
156
+ <svg class="w-3 h-3" aria-hidden="true"><use href="#icon-check"/></svg>
157
+ <%= t("glancer.info_panel.success") %>
158
+ </span>
159
+ <% else %>
160
+ <span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-400">
161
+ <svg class="w-3 h-3" aria-hidden="true"><use href="#icon-x"/></svg>
162
+ <%= t("glancer.info_panel.failed") %>
163
+ </span>
164
+ <% end %>
165
+ </div>
166
+
167
+ <%# Created at %>
168
+ <div>
169
+ <p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-2"><%= t("glancer.info_panel.created_at") %></p>
170
+ <p class="text-sm text-gray-600 dark:text-gray-300"><%= message_info.created_at.strftime("%-d de %B de %Y às %H:%M:%S") %></p>
171
+ </div>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ <% end %>
176
+ </div>
@@ -0,0 +1,100 @@
1
+ <%# Compute indexed table names for @ autocomplete.
2
+ Only paths with "#" are real table entries (e.g. "schema.rb#users").
3
+ Paths without "#" are auxiliary chunks (inflections file, etc.). %>
4
+ <% glancer_tables = begin
5
+ Glancer::Embedding.where(source_type: "schema")
6
+ .pluck(:source_path)
7
+ .filter_map do |p|
8
+ next unless p.include?("#")
9
+ n = p.split("#").last
10
+ n unless n == "foreign_keys"
11
+ end
12
+ .uniq.sort
13
+ rescue StandardError
14
+ []
15
+ end %>
16
+
17
+ <%# Temp form — submits to /start, creates chat + first message, then redirects via Turbo.visit %>
18
+ <div class="max-w-3xl lg:max-w-4xl xl:max-w-5xl 2xl:max-w-6xl mx-auto">
19
+ <form action="<%= glancer.start_session_path %>"
20
+ method="post"
21
+ data-controller="message"
22
+ data-action="submit->message#submit"
23
+ data-message-target="form"
24
+ data-message-start-url-value="<%= glancer.start_session_path %>"
25
+ data-message-tables-value="<%= h glancer_tables.to_json %>"
26
+ data-message-step-labels-value="<%= h({
27
+ enriching: t("glancer.workflow_steps.enriching"),
28
+ retrieving_context: t("glancer.workflow_steps.retrieving_context"),
29
+ generating_code: t("glancer.workflow_steps.generating_code"),
30
+ validating: t("glancer.workflow_steps.validating"),
31
+ executing: t("glancer.workflow_steps.executing"),
32
+ humanizing: t("glancer.workflow_steps.humanizing")
33
+ }.to_json) %>">
34
+ <%= hidden_field_tag :authenticity_token, form_authenticity_token %>
35
+
36
+ <div class="relative">
37
+ <%# @ mention autocomplete dropdown %>
38
+ <div id="mention-dropdown"
39
+ class="hidden absolute left-0 right-0 bottom-full mb-1 max-h-52 overflow-y-auto bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-50 text-sm"
40
+ role="listbox"
41
+ aria-label="Table suggestions"></div>
42
+
43
+ <div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm focus-within:ring-2 focus-within:ring-primary-500 focus-within:border-transparent transition-shadow">
44
+ <textarea name="content"
45
+ rows="1"
46
+ maxlength="2000"
47
+ placeholder="<%= t('glancer.chat.placeholder') %>"
48
+ class="block w-full px-4 pt-3.5 pb-2 bg-transparent resize-none outline-none text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 min-h-[42px] max-h-[200px] relative z-10"
49
+ data-message-target="input"
50
+ data-action="keydown->message#handleKeydown input->message#autoResize input->message#updateCharCount input->message#handleMentionInput keydown->message#handleMentionKeydown"
51
+ aria-label="<%= t('glancer.chat.placeholder') %>"
52
+ autocomplete="off"></textarea>
53
+
54
+ <%# @ mention chips — shown when @tablename patterns are detected %>
55
+ <div id="mention-chips"
56
+ class="hidden px-3 pb-1.5 flex flex-wrap gap-1.5"
57
+ data-message-target="mentionChips"
58
+ aria-label="Referenced tables"></div>
59
+
60
+ <div class="flex items-center justify-between px-3 pb-2.5">
61
+ <div class="flex items-center gap-3">
62
+ <span class="text-[11px] text-gray-300 dark:text-gray-600 select-none"
63
+ data-message-target="charCount"
64
+ aria-live="polite">0 / 2000</span>
65
+ <% if glancer_tables.any? %>
66
+ <span class="hidden sm:inline-flex items-center gap-1 text-[11px] text-gray-300 dark:text-gray-600 select-none" title="<%= t('glancer.chat.mention_tip_title') %>">
67
+ <kbd class="inline-flex items-center px-1 py-px rounded border border-gray-200 dark:border-gray-700 font-mono text-[10px] leading-none text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800">@</kbd>
68
+ <%= t("glancer.chat.mention_tip") %>
69
+ </span>
70
+ <% end %>
71
+ </div>
72
+ <div class="flex items-center gap-2">
73
+ <span class="hidden sm:block text-[11px] text-gray-300 dark:text-gray-600 select-none"><%= t("glancer.chat.hint") %></span>
74
+ <button type="button"
75
+ class="flex items-center justify-center w-8 h-8 rounded-xl text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-950 transition-colors"
76
+ data-message-target="micBtn"
77
+ data-action="click->message#toggleRecording"
78
+ aria-label="<%= t('glancer.chat.record') %>">
79
+ <svg class="w-4 h-4" aria-hidden="true"><use href="#icon-mic"/></svg>
80
+ </button>
81
+ <%# Cancel button — visible only while processing %>
82
+ <button type="button"
83
+ class="hidden flex items-center justify-center w-8 h-8 rounded-xl bg-gray-100 dark:bg-gray-700 hover:bg-red-50 dark:hover:bg-red-950 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
84
+ data-message-target="cancelBtn"
85
+ data-action="click->message#cancelSubmit"
86
+ aria-label="Cancel">
87
+ <svg class="w-3.5 h-3.5" aria-hidden="true"><use href="#icon-x"/></svg>
88
+ </button>
89
+ <button type="submit"
90
+ class="flex items-center justify-center w-8 h-8 rounded-xl bg-primary-600 hover:bg-primary-700 active:bg-primary-800 disabled:opacity-40 disabled:cursor-not-allowed text-white transition-colors"
91
+ data-message-target="submitBtn"
92
+ aria-label="<%= t('glancer.chat.send') %>">
93
+ <svg class="w-3.5 h-3.5" aria-hidden="true"><use href="#icon-send"/></svg>
94
+ </button>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ </form>
100
+ </div>
@@ -0,0 +1,25 @@
1
+ <%# Remove thinking indicator %>
2
+ <%= turbo_stream.remove "thinking-indicator" %>
3
+
4
+ <%# Replace temp user message with the real server-rendered one %>
5
+ <%= turbo_stream.replace "temp-user-message" do %>
6
+ <%= render "glancer/messages/message", message: @message %>
7
+ <% end %>
8
+
9
+ <%# Append the assistant processing placeholder — polling fills it in when done %>
10
+ <%= turbo_stream.append "messages-list" do %>
11
+ <%= render "glancer/messages/message", message: @response_message %>
12
+ <% end %>
13
+
14
+ <%= turbo_stream.replace "message-form" do %>
15
+ <%= render "glancer/messages/form", chat: @chat %>
16
+ <% end %>
17
+
18
+ <%= turbo_stream.replace "message-info" do %>
19
+ <%= render "glancer/messages/message_info", message_info: nil %>
20
+ <% end %>
21
+
22
+ <%# Refresh sidebar so title updates after first message %>
23
+ <%= turbo_stream.replace "sidebar-chat-list" do %>
24
+ <%= render "glancer/chats/sidebar_chat_list", chats: @chats, chat: @chat %>
25
+ <% end %>
@@ -0,0 +1,123 @@
1
+ <% content_for :main_content do %>
2
+ <div class="flex flex-col h-full overflow-hidden">
3
+
4
+ <%# Header %>
5
+ <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 flex-shrink-0">
6
+ <div class="flex items-center gap-3">
7
+ <%= link_to glancer.root_path,
8
+ class: "p-1.5 rounded-md text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors",
9
+ aria: { label: t("glancer.schema.back") },
10
+ data: { turbo: false } do %>
11
+ <svg class="w-4 h-4" aria-hidden="true"><use href="#icon-arrow-left"/></svg>
12
+ <% end %>
13
+ <div>
14
+ <h1 class="text-sm font-semibold text-gray-900 dark:text-white"><%= t("glancer.schema.title") %></h1>
15
+ <p class="text-xs text-gray-400 dark:text-gray-500"><%= t("glancer.schema.tables_count", count: @tables.size) %></p>
16
+ </div>
17
+ </div>
18
+
19
+ </div>
20
+
21
+ <div class="flex flex-col flex-1 overflow-hidden">
22
+ <%# Filter input %>
23
+ <div class="px-6 py-3 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
24
+ <div class="relative">
25
+ <svg class="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" aria-hidden="true">
26
+ <use href="#icon-filter"/>
27
+ </svg>
28
+ <input type="text"
29
+ id="table-filter"
30
+ placeholder="<%= t('glancer.schema.filter') %>"
31
+ oninput="filterTables(this.value)"
32
+ class="w-full pl-9 pr-4 py-2 text-sm bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent">
33
+ </div>
34
+ </div>
35
+
36
+ <%# Tables — CSS masonry via columns %>
37
+ <div class="flex-1 overflow-y-auto px-6 py-4">
38
+ <div id="tables-grid" style="columns: 320px; column-gap: 1rem;">
39
+ <% @tables.each do |table| %>
40
+ <div class="table-card rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden break-inside-avoid mb-4"
41
+ data-table="<%= table[:name] %>">
42
+ <%# Card header %>
43
+ <div class="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
44
+ <div class="flex items-center gap-2">
45
+ <svg class="w-3.5 h-3.5 text-primary-500" aria-hidden="true"><use href="#icon-table"/></svg>
46
+ <span class="text-sm font-semibold text-gray-800 dark:text-gray-100"><%= table[:name] %></span>
47
+ </div>
48
+ <span class="text-[11px] text-gray-400 dark:text-gray-500"><%= table[:columns].size %> <%= t("glancer.schema.cols") %></span>
49
+ </div>
50
+
51
+ <%# Column list %>
52
+ <div class="divide-y divide-gray-50 dark:divide-gray-800">
53
+ <% table[:columns].each_with_index do |col, idx| %>
54
+ <div class="flex items-center justify-between px-4 py-2 <%= idx.even? ? '' : 'bg-gray-50/50 dark:bg-gray-800/30' %>">
55
+ <div class="flex items-center gap-2">
56
+ <% if col[:name] == "id" %>
57
+ <span class="text-[9px] font-bold text-primary-500 bg-primary-50 dark:bg-primary-950 px-1.5 py-0.5 rounded uppercase">PK</span>
58
+ <% elsif table[:foreign_keys].any? { |fk| fk[:from_column] == col[:name] } %>
59
+ <span class="text-[9px] font-bold text-blue-500 bg-blue-50 dark:bg-blue-950 px-1.5 py-0.5 rounded uppercase">FK</span>
60
+ <% else %>
61
+ <span class="w-4"></span>
62
+ <% end %>
63
+ <span class="text-xs text-gray-700 dark:text-gray-300 font-mono"><%= col[:name] %></span>
64
+ </div>
65
+ <span class="text-[11px] text-gray-400 dark:text-gray-500 font-mono truncate max-w-[120px]"><%= col[:type] %></span>
66
+ </div>
67
+ <% end %>
68
+ </div>
69
+
70
+ <%# FK relationships %>
71
+ <% if table[:foreign_keys].any? %>
72
+ <div class="px-4 py-2 bg-blue-50/50 dark:bg-blue-950/20 border-t border-blue-100 dark:border-blue-900">
73
+ <% table[:foreign_keys].each do |fk| %>
74
+ <p class="text-[11px] text-blue-600 dark:text-blue-400 font-mono">
75
+ <%= fk[:from_column] %> → <%= fk[:to_table] %>.<%= fk[:to_column] %>
76
+ </p>
77
+ <% end %>
78
+ </div>
79
+ <% end %>
80
+ </div>
81
+ <% end %>
82
+ </div>
83
+
84
+ <div id="no-tables-msg" class="hidden py-12 text-center text-sm text-gray-400 dark:text-gray-500">
85
+ <%= t("glancer.schema.no_tables", term: "") %><span id="filter-term"></span>
86
+ </div>
87
+ </div>
88
+ </div>
89
+
90
+ </div>
91
+
92
+ <script>
93
+ function filterTables(query) {
94
+ var term = query.trim().toLowerCase();
95
+ var cards = document.querySelectorAll('.table-card');
96
+ var visible = 0;
97
+
98
+ cards.forEach(function(card) {
99
+ var name = card.dataset.table || '';
100
+ var match = !term || name.includes(term);
101
+ card.style.display = match ? '' : 'none';
102
+ if (match) visible++;
103
+ });
104
+
105
+ var noMsg = document.getElementById('no-tables-msg');
106
+ var filterTerm = document.getElementById('filter-term');
107
+ noMsg.classList.toggle('hidden', visible > 0 || !term);
108
+ if (filterTerm) filterTerm.textContent = query;
109
+ }
110
+
111
+ // Auto-apply filter from ?table= URL param (used by @mention links)
112
+ (function() {
113
+ var params = new URLSearchParams(location.search);
114
+ var table = params.get('table');
115
+ if (table) {
116
+ var input = document.getElementById('table-filter');
117
+ if (input) { input.value = table; filterTables(table); }
118
+ var card = document.querySelector('[data-table="' + table + '"]');
119
+ if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
120
+ }
121
+ })();
122
+ </script>
123
+ <% end %>