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,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ class MessagesController < Glancer::ApplicationController
5
+ def create
6
+ @chat = Glancer::Chat.find(params[:chat_id])
7
+ @message = @chat.messages.create!(message_params.merge(role: :user))
8
+
9
+ # Placeholder written immediately; ProcessMessageJob fills it in async.
10
+ @response_message = @chat.messages.create!(
11
+ role: :assistant,
12
+ content: "",
13
+ code_type: "sql",
14
+ user_message: @message,
15
+ status: :processing
16
+ )
17
+
18
+ Glancer::AsyncRunner.call(@response_message.id, @message.content)
19
+
20
+ @chats = Glancer::Chat.order(created_at: :desc)
21
+
22
+ respond_to do |format|
23
+ format.turbo_stream
24
+ format.html { redirect_to glancer.chat_path(@chat) }
25
+ end
26
+ end
27
+
28
+ # Clients poll this endpoint every ~2 s while waiting for the async runner.
29
+ # Returns 204 while still processing; Turbo Stream when complete or failed.
30
+ # A hard timeout marks stale messages as failed so the UI doesn't loop forever.
31
+ PROCESSING_TIMEOUT_SECONDS = 300
32
+
33
+ def poll
34
+ @message = Glancer::Message.find(params[:id])
35
+
36
+ if (@message.processing? || @message.pending?) &&
37
+ @message.updated_at < PROCESSING_TIMEOUT_SECONDS.seconds.ago
38
+ @message.update!(content: "Request timed out. Please try again.", successful: false, status: :failed)
39
+ end
40
+
41
+ return head(:no_content) if @message.processing? || @message.pending?
42
+
43
+ respond_to do |format|
44
+ format.turbo_stream do
45
+ render turbo_stream: turbo_stream.replace(
46
+ "message-#{@message.id}",
47
+ partial: "glancer/messages/message",
48
+ locals: { message: @message }
49
+ )
50
+ end
51
+ end
52
+ end
53
+
54
+ def run_code
55
+ @message = Glancer::Message.find(params[:id])
56
+ custom_code = params[:custom_code].presence
57
+
58
+ if custom_code
59
+ if @message.code_type == "activerecord"
60
+ Glancer::Workflow::ARSanitizer.ensure_safe!(custom_code)
61
+ @message.update!(code: custom_code, user_edited_code: true)
62
+ @message.code_versions.create!(code: custom_code, source: :user_edited)
63
+ @data = Glancer::Workflow::ARExecutor.execute(@message.code, message_id: @message.id)
64
+ else
65
+ Glancer::Workflow::SQLSanitizer.ensure_safe!(custom_code)
66
+ @message.update!(code: custom_code, user_edited_code: true)
67
+ @message.code_versions.create!(code: custom_code, source: :user_edited)
68
+ @data = Glancer::Workflow::Executor.execute(@message.code, message_id: @message.id)
69
+ end
70
+ elsif @message.code_type == "activerecord"
71
+ @data = Glancer::Workflow::ARExecutor.execute(@message.code, message_id: @message.id)
72
+ else
73
+ @data = Glancer::Workflow::Executor.execute(@message.code, message_id: @message.id)
74
+ end
75
+
76
+ respond_to do |format|
77
+ format.turbo_stream do
78
+ if @data.is_a?(Hash) && @data[:error]
79
+ render turbo_stream: turbo_stream.update("results-#{@message.id}",
80
+ partial: "glancer/messages/execution_error",
81
+ locals: { error_message: @data[:message] })
82
+ else
83
+ chart_data = Glancer::ChartAnalyzer.analyze(@data)
84
+ render turbo_stream: turbo_stream.update("results-#{@message.id}",
85
+ partial: "glancer/messages/data_table",
86
+ locals: { data: @data, chart_data: chart_data })
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ def open_in_blazer
93
+ @message = Glancer::Message.find(params[:id])
94
+ blazer_path = Glancer.configuration.resolved_blazer_path
95
+
96
+ unless blazer_path.present? && defined?(Blazer::Query)
97
+ redirect_to glancer.root_path, alert: "Blazer não está disponível."
98
+ return
99
+ end
100
+
101
+ unless @message.code_type == "sql" || @message.code_type.nil?
102
+ redirect_to glancer.root_path, alert: "Only SQL queries can be opened in Blazer."
103
+ return
104
+ end
105
+
106
+ query = Blazer::Query.create!(
107
+ name: "Glancer: #{@message.user_message&.content&.truncate(60) || "Query"}",
108
+ statement: @message.code.strip
109
+ )
110
+
111
+ redirect_to "#{blazer_path}/queries/#{query.id}/edit", allow_other_host: true
112
+ rescue StandardError => e
113
+ Glancer::Utils::Logger.error("MessagesController", "Failed to create Blazer query: #{e.message}")
114
+ redirect_to glancer.root_path, alert: "Não foi possível criar a query no Blazer: #{e.message}"
115
+ end
116
+
117
+ def message_info
118
+ @message_for_info = begin
119
+ Glancer::Message.find(params[:id])
120
+ rescue StandardError
121
+ nil
122
+ end
123
+
124
+ respond_to do |format|
125
+ format.turbo_stream do
126
+ render turbo_stream: [
127
+ turbo_stream.replace("message-info", partial: "glancer/messages/message_info",
128
+ locals: { message_info: @message_for_info })
129
+ ]
130
+ end
131
+ end
132
+ end
133
+
134
+ private
135
+
136
+ def message_params
137
+ params.require(:message).permit(:content)
138
+ end
139
+
140
+ def format_response(result)
141
+ result[:content]
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ class SchemaController < Glancer::ApplicationController
5
+ layout "glancer/application"
6
+
7
+ def show
8
+ @chats = Glancer::Chat.order(created_at: :desc)
9
+ connection = ActiveRecord::Base.connection
10
+ @tables = connection.tables.sort.filter_map do |table_name|
11
+ next if %w[schema_migrations ar_internal_metadata].include?(table_name)
12
+
13
+ columns = connection.columns(table_name).map do |col|
14
+ { name: col.name, type: col.sql_type, null: col.null, default: col.default }
15
+ end
16
+
17
+ fks = begin
18
+ connection.foreign_keys(table_name).map do |fk|
19
+ { from_column: fk.column, to_table: fk.to_table, to_column: fk.primary_key }
20
+ end
21
+ rescue StandardError
22
+ []
23
+ end
24
+
25
+ { name: table_name, columns: columns, foreign_keys: fks }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ class SettingsController < ApplicationController
5
+ layout "glancer/application"
6
+
7
+ def show
8
+ @chats = Glancer::Chat.order(created_at: :desc)
9
+ @settings = {
10
+ ui_language: Glancer::Setting.get("ui_language", default: "en"),
11
+ speech_language: Glancer::Setting.get("speech_language", default: "auto"),
12
+ custom_instructions: Glancer::Setting.get("custom_instructions", default: "")
13
+ }
14
+ @glancer_config = Glancer.configuration
15
+ end
16
+
17
+ def update
18
+ allowed = params.require(:settings).permit(:ui_language, :speech_language, :custom_instructions)
19
+ Glancer::Setting.store_many(allowed.to_h)
20
+ redirect_to glancer.settings_path, notice: t("glancer.settings.saved")
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ module ApplicationHelper
5
+ # Returns the list of indexed table names once per request (memoized).
6
+ def glancer_table_names
7
+ @glancer_table_names ||= Glancer::Embedding
8
+ .where(source_type: "schema")
9
+ .pluck(:source_path)
10
+ .filter_map { |p| p.split("#").last if p.include?("#") }
11
+ .reject { |n| n == "foreign_keys" }
12
+ .uniq
13
+ rescue StandardError
14
+ []
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ class ApplicationJob < ActiveJob::Base
5
+ end
6
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ class ProcessMessageJob < Glancer::ApplicationJob
5
+ queue_as :default
6
+
7
+ def perform(message_id, question)
8
+ message = Glancer::Message.find(message_id)
9
+ chat = message.chat
10
+
11
+ message.update!(status: :processing)
12
+
13
+ result = Glancer::Workflow.run(chat.id, question)
14
+
15
+ cfg = Glancer.configuration
16
+ used_model = "#{cfg.resolved_chat_provider}/#{cfg.resolved_chat_model}"
17
+
18
+ message.update!(
19
+ content: result[:content].to_s,
20
+ code: result[:code],
21
+ code_type: result[:code_type] || "sql",
22
+ successful: result[:successful],
23
+ llm_model: used_model,
24
+ enriched_question: result[:enriched_question],
25
+ status: :complete
26
+ )
27
+
28
+ message.code_versions.create!(code: message.code, source: :generated) if message.code.present?
29
+
30
+ # Generate chat title on the first user message
31
+ chat.update!(title: Glancer::Workflow::LLM.generate_title(question)) if chat.messages.where(role: :user).count == 1
32
+ rescue StandardError => e
33
+ Glancer::Utils::Logger.error("ProcessMessageJob", "Failed for message #{message_id}: #{e.message}")
34
+ message&.update!(content: e.message, successful: false, status: :failed)
35
+ raise
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ class Audit < ApplicationRecord
5
+ self.table_name = "glancer_audits"
6
+
7
+ belongs_to :message, class_name: "Glancer::Message", optional: true
8
+
9
+ validates :code, :adapter, :run_id, :executed_at, presence: true
10
+ validates :run_id, uniqueness: true
11
+ end
12
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ class Chat < ApplicationRecord
5
+ has_many :messages, class_name: "Glancer::Message", dependent: :destroy
6
+ validates :title, presence: true
7
+ end
8
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ class CodeVersion < ApplicationRecord
5
+ self.table_name = "glancer_code_versions"
6
+
7
+ belongs_to :message, class_name: "Glancer::Message"
8
+ enum :source, generated: "generated", user_edited: "user_edited"
9
+
10
+ validates :code, :source, presence: true
11
+ end
12
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ class Embedding < ApplicationRecord
5
+ end
6
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ class Message < ApplicationRecord
5
+ belongs_to :chat, class_name: "Glancer::Chat"
6
+ belongs_to :user_message, class_name: "Glancer::Message", optional: true
7
+ has_many :code_versions, class_name: "Glancer::CodeVersion", dependent: :destroy
8
+ has_many :audits, class_name: "Glancer::Audit", foreign_key: :message_id, dependent: :nullify
9
+
10
+ # Nullify self-referential FK before destroy to avoid MySQL constraint violation
11
+ # when the chat cascade reaches user messages before their assistant counterparts
12
+ before_destroy { self.class.where(user_message_id: id).update_all(user_message_id: nil) }
13
+ enum :role, user: "user", assistant: "assistant", system: "system"
14
+ enum :status, { pending: 0, processing: 1, complete: 2, failed: 3 }, default: :complete
15
+
16
+ # User messages always require content; assistant placeholders start empty.
17
+ validates :content, presence: true, if: :user?
18
+
19
+ def sql_result_json
20
+ JSON.parse(content || "[]")
21
+ rescue JSON::ParserError
22
+ []
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ class Setting < ApplicationRecord
5
+ self.table_name = "glancer_settings"
6
+
7
+ validates :key, presence: true, uniqueness: true
8
+
9
+ def self.get(key, default: nil)
10
+ find_by(key: key.to_s)&.value || default
11
+ end
12
+
13
+ def self.set(key, value)
14
+ record = find_or_initialize_by(key: key.to_s)
15
+ record.value = value.to_s
16
+ record.save!
17
+ end
18
+
19
+ def self.store_many(hash)
20
+ hash.each { |k, v| set(k, v) }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ # Deprecated: use Glancer::CodeVersion instead.
5
+ SqlVersion = CodeVersion
6
+ end
@@ -0,0 +1,7 @@
1
+ {
2
+ "imports": {
3
+ "application": "/assets/glancer/application.js",
4
+ "@hotwired/turbo-rails": "https://cdn.jsdelivr.net/npm/@hotwired/turbo-rails/+esm",
5
+ "@hotwired/stimulus": "https://cdn.jsdelivr.net/npm/@hotwired/stimulus/+esm"
6
+ }
7
+ }
@@ -0,0 +1,2 @@
1
+ <%# This partial is no longer used — the sidebar is rendered directly in the layout. %>
2
+ <%# Kept as a stub to avoid missing partial errors from any cached turbo streams. %>
@@ -0,0 +1,52 @@
1
+ <% if chat %>
2
+ <div class="flex-1 flex flex-col min-h-0">
3
+ <div id="chat-messages"
4
+ class="flex-1 overflow-y-auto py-6 flex flex-col"
5
+ role="log"
6
+ aria-live="polite"
7
+ aria-label="Conversation messages">
8
+ <div id="messages-list" class="flex-1 flex flex-col max-w-3xl lg:max-w-4xl xl:max-w-5xl 2xl:max-w-6xl mx-auto px-4 space-y-6 w-full">
9
+ <% if messages.blank? %>
10
+ <div id="chat-empty-state" class="flex-1 flex flex-col items-center justify-center text-center">
11
+ <div class="w-14 h-14 rounded-2xl bg-primary-50 dark:bg-primary-950 flex items-center justify-center mb-4">
12
+ <svg class="w-7 h-7 text-primary-600 dark:text-primary-400" aria-hidden="true"><use href="#icon-database"/></svg>
13
+ </div>
14
+ <h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-1"><%= t("glancer.chat.empty_title") %></h2>
15
+ <p class="text-sm text-gray-400 dark:text-gray-500 max-w-xs"><%= t("glancer.chat.empty_subtitle") %></p>
16
+ </div>
17
+ <% else %>
18
+ <% messages.each do |message| %>
19
+ <%= render "glancer/messages/message", message: message %>
20
+ <% end %>
21
+ <% end %>
22
+ </div><%# /messages-list %>
23
+ </div>
24
+
25
+ <div class="flex-shrink-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 px-4 py-4">
26
+ <%= render "glancer/messages/form", chat: chat %>
27
+ </div>
28
+ </div>
29
+
30
+ <% else %>
31
+ <%# Temporary chat — no DB record yet. Chat is created on first message send. %>
32
+ <div class="flex-1 flex flex-col min-h-0">
33
+ <div id="chat-messages"
34
+ class="flex-1 overflow-y-auto py-6 flex flex-col"
35
+ role="log"
36
+ aria-live="polite">
37
+ <div id="messages-list" class="flex-1 flex flex-col max-w-3xl lg:max-w-4xl xl:max-w-5xl 2xl:max-w-6xl mx-auto px-4 space-y-6 w-full">
38
+ <div id="chat-empty-state" class="flex-1 flex flex-col items-center justify-center text-center">
39
+ <div class="w-14 h-14 rounded-2xl bg-primary-50 dark:bg-primary-950 flex items-center justify-center mb-4">
40
+ <svg class="w-7 h-7 text-primary-600 dark:text-primary-400" aria-hidden="true"><use href="#icon-database"/></svg>
41
+ </div>
42
+ <h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-1"><%= t("glancer.chat.empty_title") %></h2>
43
+ <p class="text-sm text-gray-400 dark:text-gray-500 max-w-xs"><%= t("glancer.chat.empty_subtitle") %></p>
44
+ </div>
45
+ </div><%# /messages-list %>
46
+ </div>
47
+
48
+ <div class="flex-shrink-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 px-4 py-4">
49
+ <%= render "glancer/messages/temp_form" %>
50
+ </div>
51
+ </div>
52
+ <% end %>
@@ -0,0 +1,30 @@
1
+ <div id="sidebar-chat-list" class="flex-1 overflow-y-auto px-2 py-1">
2
+ <% if @chats.blank? %>
3
+ <p class="px-3 py-6 text-xs text-center text-gray-400 dark:text-gray-500">No chats yet. Create one to get started.</p>
4
+ <% else %>
5
+ <% @chats.each do |c| %>
6
+ <div class="group relative flex items-center rounded-lg my-0.5 <%= c == @chat ? 'bg-primary-50 dark:bg-primary-950' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %> transition-colors">
7
+ <%= link_to glancer.chat_path(c),
8
+ class: "flex items-center gap-2.5 flex-1 min-w-0 px-3 py-2.5",
9
+ data: { action: "click->chat#select", chat_id: c.id, turbo: "false" },
10
+ aria: { current: (c == @chat ? "page" : nil) } do %>
11
+ <svg class="w-3.5 h-3.5 flex-shrink-0 <%= c == @chat ? 'text-primary-600 dark:text-primary-400' : 'text-gray-400' %>" aria-hidden="true">
12
+ <use href="#icon-chat"/>
13
+ </svg>
14
+ <span class="truncate text-sm <%= c == @chat ? 'font-medium text-primary-700 dark:text-primary-300' : 'text-gray-700 dark:text-gray-300' %>">
15
+ <%= c.title %>
16
+ </span>
17
+ <% end %>
18
+
19
+ <div class="flex-shrink-0 pr-2 opacity-0 group-hover:opacity-100 transition-opacity">
20
+ <%= link_to glancer.chat_path(c),
21
+ class: "p-1 rounded text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors",
22
+ data: { turbo_method: :delete, turbo_confirm: "Delete this chat?" },
23
+ aria: { label: "Delete chat" } do %>
24
+ <svg class="w-3.5 h-3.5" aria-hidden="true"><use href="#icon-trash"/></svg>
25
+ <% end %>
26
+ </div>
27
+ </div>
28
+ <% end %>
29
+ <% end %>
30
+ </div>
@@ -0,0 +1,10 @@
1
+ <div class="flex h-full w-full">
2
+ <%= render "glancer/chats/chat_sidebar" %>
3
+
4
+ <div id="main-content" class="flex-1 flex flex-col bg-white dark:bg-gray-900 relative">
5
+ <%= render "glancer/chats/show",
6
+ chat: @chat || @chats.first,
7
+ chats: @chats,
8
+ messages: (@chat || @chats.first)&.messages || [] %>
9
+ </div>
10
+ </div>
@@ -0,0 +1 @@
1
+ <%# Layout renders the chat UI via _show partial using @chat / @chats instance variables %>