bugsage 0.2.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 (69) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +442 -0
  3. data/CHANGELOG.md +28 -0
  4. data/CODE_OF_CONDUCT.md +10 -0
  5. data/CONTRIBUTING.md +301 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +344 -0
  8. data/ROADMAP.md +217 -0
  9. data/SECURITY.md +83 -0
  10. data/docs/AI.md +167 -0
  11. data/docs/Configuration.md +168 -0
  12. data/docs/GettingStarted.md +146 -0
  13. data/docs/RELEASE_CHECKLIST.md +346 -0
  14. data/docs/Troubleshooting.md +181 -0
  15. data/docs/images/BadRequest.png +0 -0
  16. data/docs/images/BadRequest2.png +0 -0
  17. data/docs/images/BugSage_Logo.png +0 -0
  18. data/docs/images/BugSage_Social_Preview.png +0 -0
  19. data/docs/images/NoMethodError.png +0 -0
  20. data/docs/images/NoMethodError2.png +0 -0
  21. data/docs/images/README.md +38 -0
  22. data/docs/releases/README.md +21 -0
  23. data/docs/releases/v0.2.0.md +40 -0
  24. data/exe/bugsage +7 -0
  25. data/lib/bugsage/ai_analyzer.rb +145 -0
  26. data/lib/bugsage/ai_chat.rb +388 -0
  27. data/lib/bugsage/ai_context.rb +69 -0
  28. data/lib/bugsage/ai_panel.rb +708 -0
  29. data/lib/bugsage/ai_support.rb +36 -0
  30. data/lib/bugsage/auto_configurator.rb +97 -0
  31. data/lib/bugsage/cli.rb +59 -0
  32. data/lib/bugsage/code_context.rb +81 -0
  33. data/lib/bugsage/code_patch.rb +147 -0
  34. data/lib/bugsage/configuration.rb +149 -0
  35. data/lib/bugsage/console_context.rb +51 -0
  36. data/lib/bugsage/cursor_client.rb +165 -0
  37. data/lib/bugsage/dashboard.rb +627 -0
  38. data/lib/bugsage/editor_links.rb +34 -0
  39. data/lib/bugsage/error_page.rb +298 -0
  40. data/lib/bugsage/exception_handler.rb +66 -0
  41. data/lib/bugsage/exception_support.rb +95 -0
  42. data/lib/bugsage/exceptions_app.rb +31 -0
  43. data/lib/bugsage/fix_applicator.rb +107 -0
  44. data/lib/bugsage/formatter.rb +37 -0
  45. data/lib/bugsage/http_error_capture.rb +104 -0
  46. data/lib/bugsage/http_response_error.rb +14 -0
  47. data/lib/bugsage/inline_console.rb +226 -0
  48. data/lib/bugsage/installation.rb +94 -0
  49. data/lib/bugsage/installer.rb +66 -0
  50. data/lib/bugsage/json_endpoint.rb +34 -0
  51. data/lib/bugsage/locales/en.yml +439 -0
  52. data/lib/bugsage/middleware.rb +152 -0
  53. data/lib/bugsage/openai_client.rb +103 -0
  54. data/lib/bugsage/page_actions.rb +255 -0
  55. data/lib/bugsage/railtie.rb +49 -0
  56. data/lib/bugsage/request_context.rb +56 -0
  57. data/lib/bugsage/rule.rb +271 -0
  58. data/lib/bugsage/session_clear.rb +35 -0
  59. data/lib/bugsage/store.rb +60 -0
  60. data/lib/bugsage/suggestion.rb +58 -0
  61. data/lib/bugsage/trace_cleaner.rb +24 -0
  62. data/lib/bugsage/translations.rb +85 -0
  63. data/lib/bugsage/version.rb +5 -0
  64. data/lib/bugsage.rb +65 -0
  65. data/lib/generators/bugsage/install/install_generator.rb +21 -0
  66. data/lib/generators/bugsage/install/templates/bugsage.rb +22 -0
  67. data/scripts/publish-github-release.sh +38 -0
  68. data/sig/bugsage.rbs +4 -0
  69. metadata +157 -0
@@ -0,0 +1,38 @@
1
+ # Image assets
2
+
3
+ ## Logo & social
4
+
5
+ | File | Shows |
6
+ |------|--------|
7
+ | `BugSage_Logo.png` | Project logo / wordmark banner (magnifying-glass bug + BugSage) |
8
+ | `BugSage_Social_Preview.png` | GitHub / social Open Graph image — logo, tagline, feature grid, dashboard preview |
9
+
10
+ ### Set the GitHub Social Preview
11
+
12
+ GitHub does not auto-detect this file. After committing:
13
+
14
+ 1. Open [Repository Settings → General → Social preview](https://github.com/MONARCHKOLI/bugsage/settings)
15
+ 2. Upload `docs/images/BugSage_Social_Preview.png`
16
+ 3. Save
17
+
18
+ GitHub recommends about **1280×640** px (minimum ~640×320). This asset also works when sharing the repo on X, LinkedIn, and Slack.
19
+
20
+ ## Screenshots
21
+
22
+ PNG captures used by the root [README.md](../README.md) **Screenshots** section. Most were taken from [`examples/sample_app`](../examples/sample_app).
23
+
24
+ | File | Shows |
25
+ |------|--------|
26
+ | `NoMethodError2.png` | Exception page for `GET /boom` |
27
+ | `NoMethodError.png` | Dashboard detail + AI Suggestions for `NoMethodError` |
28
+ | `BadRequest.png` | Dashboard detail for captured `HTTP 400 Response` |
29
+ | `BadRequest2.png` | Raw JSON body still returned by `GET /bad_request` |
30
+
31
+ ## Still useful to add
32
+
33
+ | File | Suggested capture |
34
+ |------|-------------------|
35
+ | `cli-usage.png` | Terminal output for `bugsage version` / `bugsage install` |
36
+ | `ai-suggestions-chat.png` | Optional: Quick Fix result + follow-up chat with code patch |
37
+
38
+ Tips: crop to the relevant UI, avoid secrets, keep width readable on GitHub (~1200–1600px).
@@ -0,0 +1,21 @@
1
+ # BugSage release notes
2
+
3
+ Human-readable GitHub Release bodies live here. Create one file per version:
4
+
5
+ ```text
6
+ docs/releases/vX.Y.Z.md
7
+ ```
8
+
9
+ Publish with:
10
+
11
+ ```bash
12
+ gh release create vX.Y.Z \
13
+ --title "BugSage vX.Y.Z" \
14
+ --notes-file docs/releases/vX.Y.Z.md
15
+ ```
16
+
17
+ See [RELEASE_CHECKLIST.md](../RELEASE_CHECKLIST.md) § GitHub Release.
18
+
19
+ ## Releases
20
+
21
+ - [v0.2.0](v0.2.0.md)
@@ -0,0 +1,40 @@
1
+ # BugSage v0.2.0
2
+
3
+ **Release date:** 2026-07-13
4
+
5
+ ## What's New
6
+
7
+ - AI-powered Rails exception analysis (OpenAI / Cursor on demand, Quick Fix + chat, surgical code patches)
8
+ - Dashboard improvements (`/bugsage` session UI, fix actions, clear session)
9
+ - Better configuration (`config.bugsage`, env-based enablement, safer environment defaults)
10
+ - Zero-config Rails install (`AutoConfigurator`, `bugsage install`, optional generator)
11
+ - Inline Rails console on the error page and dashboard
12
+ - Improved documentation (README, Getting Started, Configuration, AI, Troubleshooting, screenshots)
13
+ - GitHub Actions CI (RSpec, RuboCop, bundler-audit, gem build)
14
+ - Security policy (`SECURITY.md`)
15
+ - Architecture documentation (`ARCHITECTURE.md`) and roadmap (`ROADMAP.md`)
16
+
17
+ ## Install
18
+
19
+ ```ruby
20
+ # Gemfile
21
+ gem "bugsage", "~> 0.2.0"
22
+ ```
23
+
24
+ ```bash
25
+ bundle install
26
+ bin/rails server
27
+ # Trigger an error in development, then open http://localhost:3000/bugsage
28
+ ```
29
+
30
+ Optional AI keys:
31
+
32
+ ```bash
33
+ export OPENAI_API_KEY=sk-...
34
+ # or
35
+ export CURSOR_API_KEY=crsr_...
36
+ ```
37
+
38
+ ## Full changelog
39
+
40
+ See [CHANGELOG.md — 0.2.0](../../CHANGELOG.md#020---2026-07-13).
data/exe/bugsage ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/bugsage"
5
+ require_relative "../lib/bugsage/cli"
6
+
7
+ Bugsage::CLI.start(ARGV)
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Bugsage
6
+ class AiAnalyzer
7
+ SYSTEM_PROMPT = <<~PROMPT
8
+ You are BugSage, a Ruby on Rails debugging assistant.
9
+ Refine the provided rule-based analysis using the exception details and request context.
10
+ Respond with JSON only using this schema:
11
+ {
12
+ "root_cause": "string",
13
+ "fixes": ["string", "string"],
14
+ "confidence": 0,
15
+ "notes": "string",
16
+ "code_patch": {
17
+ "action": "replace_lines | delete_lines | insert_before | no_change",
18
+ "start_line": 0,
19
+ "end_line": 0,
20
+ "replacement": "string"
21
+ }
22
+ }
23
+ Keep fixes concrete and actionable. Confidence must be an integer from 0 to 100.
24
+
25
+ You receive numbered source code from the real file. Rules for code_patch:
26
+ - Read the FULL source context before suggesting changes.
27
+ - Use absolute line numbers from the numbered source (the line marked with >>).
28
+ - NEVER duplicate code that already exists in the file.
29
+ - Change ONLY the lines required to fix the error.
30
+ - To remove a line (e.g. stray raise, debug code), use delete_lines with empty replacement.
31
+ - To replace lines, use replace_lines with start_line/end_line covering only affected lines.
32
+ - If the file already contains the correct fix, use no_change.
33
+ - Do not repeat existing assignments, finds, or renders.
34
+ - replacement must be only the new Ruby code for the affected lines, without markdown fences.
35
+ PROMPT
36
+
37
+ def self.enhance(suggestion, exception, context = {}, config: Bugsage.configuration, client: nil)
38
+ new(config: config, client: client).enhance(suggestion, exception, context)
39
+ end
40
+
41
+ def initialize(config: Bugsage.configuration, client: nil)
42
+ @config = config
43
+ @client = client
44
+ end
45
+
46
+ # Returns [suggestion, ai_error_message]
47
+ def enhance(suggestion, exception, context)
48
+ return [suggestion, nil] unless @config.ai_configured?
49
+
50
+ response = client.complete(
51
+ system_prompt: SYSTEM_PROMPT,
52
+ user_prompt: build_prompt(suggestion, exception, context)
53
+ )
54
+ [merge_suggestion(suggestion, parse_response(response, suggestion: suggestion)), nil]
55
+ rescue StandardError => e
56
+ log_failure(e)
57
+ [suggestion, e.message]
58
+ end
59
+
60
+ private
61
+
62
+ def client
63
+ @client ||= @config.ai_client || build_client
64
+ end
65
+
66
+ def build_client
67
+ AiSupport.build_client(@config)
68
+ end
69
+
70
+ def build_prompt(suggestion, exception, context)
71
+ backtrace = TraceCleaner.clean(exception.backtrace).first(8).join("\n")
72
+ request_context = context.empty? ? "none" : JSON.pretty_generate(context)
73
+ file_path, line_number = CodeContext.extract_location(suggestion.location)
74
+ source_context = CodeContext.numbered_source(file_path, line_number) if file_path && line_number
75
+ source_block = source_context ? source_context[:source] : "unavailable"
76
+
77
+ <<~PROMPT
78
+ Exception class: #{exception.class.name}
79
+ Exception message: #{exception.message}
80
+ Location: #{suggestion.location}
81
+ Error line: #{line_number}
82
+ Backtrace:
83
+ #{backtrace}
84
+
85
+ Request context:
86
+ #{request_context}
87
+
88
+ Numbered source code:
89
+ #{source_block}
90
+
91
+ Rule-based analysis:
92
+ Issue: #{suggestion.issue}
93
+ Root cause: #{suggestion.root_cause}
94
+ Fixes: #{suggestion.fixes.join("; ")}
95
+ Confidence: #{suggestion.confidence}
96
+ PROMPT
97
+ end
98
+
99
+ def parse_response(response, suggestion:)
100
+ payload = JSON.parse(extract_json(response))
101
+ _file_path, line_number = CodeContext.extract_location(suggestion.location)
102
+ patch = CodePatch.from_ai(payload, error_line: line_number || 1)
103
+
104
+ {
105
+ root_cause: payload["root_cause"].to_s.strip,
106
+ fixes: Array(payload["fixes"]).map { |fix| fix.to_s.strip }.reject(&:empty?),
107
+ confidence: normalize_confidence(payload["confidence"]),
108
+ notes: payload["notes"].to_s.strip,
109
+ code_patch: patch&.to_h
110
+ }
111
+ end
112
+
113
+ def merge_suggestion(suggestion, ai_result)
114
+ return suggestion if ai_result[:fixes].empty? && ai_result[:root_cause].empty? && ai_result[:code_patch].nil?
115
+
116
+ suggestion.with_ai_enhancement(
117
+ root_cause: ai_result[:root_cause].empty? ? suggestion.root_cause : ai_result[:root_cause],
118
+ fixes: merge_fixes(suggestion.fixes, ai_result[:fixes]),
119
+ confidence: ai_result[:confidence] || suggestion.confidence,
120
+ ai_notes: ai_result[:notes].empty? ? nil : ai_result[:notes],
121
+ code_patch: ai_result[:code_patch]
122
+ )
123
+ end
124
+
125
+ def merge_fixes(rule_fixes, ai_fixes)
126
+ combined = ai_fixes + rule_fixes
127
+ combined.uniq(&:downcase)
128
+ end
129
+
130
+ def normalize_confidence(value)
131
+ number = Integer(value)
132
+ number.clamp(0, 100)
133
+ rescue ArgumentError, TypeError
134
+ nil
135
+ end
136
+
137
+ def extract_json(response)
138
+ AiSupport.extract_json(response)
139
+ end
140
+
141
+ def log_failure(error)
142
+ AiSupport.log_failure("AI enhancement failed", error)
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,388 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Bugsage
6
+ class AiChat
7
+ extend JsonEndpoint
8
+
9
+ ENDPOINT = "/bugsage/ai-chat"
10
+
11
+ SYSTEM_PROMPT = <<~PROMPT
12
+ You are BugSage, a Ruby on Rails debugging assistant chatting with a developer
13
+ about a specific caught exception.
14
+
15
+ Respond with JSON only using this schema:
16
+ {
17
+ "reply": "conversational answer shown in chat",
18
+ "code_patch": null | {
19
+ "action": "replace_lines | delete_lines | insert_before | no_change",
20
+ "start_line": 0,
21
+ "end_line": 0,
22
+ "replacement": "string"
23
+ }
24
+ }
25
+
26
+ Rules:
27
+ - reply must explain your answer clearly and concisely.
28
+ - Include code_patch ONLY when the developer asks for a code change, asks to
29
+ revise the current suggested fix, or agrees to an alternative approach
30
+ (for example commenting out a line instead of deleting it).
31
+ - When they want to comment out a line, use replace_lines with the commented
32
+ Ruby code in replacement (e.g. "# raise "debug""), not delete_lines.
33
+ - Use absolute line numbers from the numbered source (the line marked with >>).
34
+ - NEVER duplicate code that already exists in the file.
35
+ - Change ONLY the lines required for the requested fix.
36
+ - Set code_patch to null when answering questions without changing the fix.
37
+ - replacement must be only the new Ruby code for affected lines, without markdown fences.
38
+ PROMPT
39
+
40
+ def self.handle_request(env)
41
+ return not_found unless Bugsage.configuration.ai_configured?
42
+
43
+ payload = parse_request_body(env)
44
+ context = AiPanel.load_context(payload)
45
+ return json_response(error_response(Bugsage.t("errors.ai_context_not_available"))) unless context
46
+
47
+ message = payload["message"].to_s.strip
48
+ return json_response(error_response(Bugsage.t("errors.enter_message"))) if message.empty?
49
+
50
+ history = normalize_history(payload["history"])
51
+ result = chat(context, message, history)
52
+
53
+ updated_history = history + [
54
+ { "role" => "user", "content" => message },
55
+ { "role" => "assistant", "content" => result[:reply] }
56
+ ]
57
+
58
+ persist_chat_patch!(context, result[:code_patch], payload["index"]) if result[:code_patch]
59
+
60
+ json_response(
61
+ ok: true,
62
+ reply: result[:reply],
63
+ history: updated_history,
64
+ code_patch: result[:code_patch],
65
+ code_fix: result[:code_fix]
66
+ )
67
+ rescue StandardError => e
68
+ log_failure(e)
69
+ json_response(error_response(e.message))
70
+ end
71
+
72
+ def self.chat(context, message, history)
73
+ client = build_client
74
+ user_prompt = build_chat_prompt(context, message, history)
75
+ response = client.complete(system_prompt: SYSTEM_PROMPT, user_prompt: user_prompt)
76
+ parse_chat_response(response, context[:suggestion])
77
+ end
78
+
79
+ def self.build_chat_prompt(context, message, history)
80
+ context_block = build_context_block(context)
81
+ conversation = history.map do |entry|
82
+ "#{entry["role"].capitalize}: #{entry["content"]}"
83
+ end.join("\n\n")
84
+
85
+ parts = [context_block]
86
+ parts << "Conversation so far:\n#{conversation}" unless conversation.empty?
87
+ parts << "Developer: #{message}"
88
+ parts.join("\n\n")
89
+ end
90
+
91
+ def self.parse_chat_response(response, suggestion)
92
+ payload = JSON.parse(extract_json(response))
93
+ _file_path, line_number = CodeContext.extract_location(suggestion.location)
94
+ code_patch = parse_code_patch(payload, line_number)
95
+
96
+ reply = payload["reply"].to_s.strip
97
+ reply = payload["notes"].to_s.strip if reply.empty?
98
+
99
+ {
100
+ reply: reply.empty? ? Bugsage.t("errors.default_chat_reply") : reply,
101
+ code_patch: code_patch,
102
+ code_fix: CodePatch.preview_for(code_patch)
103
+ }
104
+ end
105
+
106
+ def self.parse_code_patch(payload, line_number)
107
+ patch_data = payload["code_patch"]
108
+ return nil if patch_data.nil? || (patch_data.respond_to?(:empty?) && patch_data.empty?)
109
+
110
+ patch = CodePatch.from_ai(payload, error_line: line_number || 1)
111
+ patch&.to_h
112
+ end
113
+
114
+ def self.persist_chat_patch!(context, code_patch, index)
115
+ return unless code_patch.is_a?(Hash) && !code_patch.empty?
116
+
117
+ suggestion = context[:suggestion]
118
+ updated = suggestion.with_ai_enhancement(
119
+ root_cause: suggestion.root_cause,
120
+ fixes: suggestion.fixes,
121
+ confidence: suggestion.confidence,
122
+ ai_notes: suggestion.ai_notes,
123
+ code_patch: code_patch
124
+ )
125
+
126
+ if index
127
+ Store.update_at(index.to_i, updated)
128
+ elsif Store.all.any?
129
+ Store.update_at(0, updated)
130
+ end
131
+ end
132
+
133
+ def self.build_context_block(context, source_context = nil)
134
+ suggestion = context[:suggestion]
135
+ exception = context[:exception]
136
+ file_path, line_number = CodeContext.extract_location(suggestion.location)
137
+ source_context ||= CodeContext.numbered_source(file_path, line_number) if file_path && line_number
138
+ source_block = source_context ? source_context[:source] : "unavailable"
139
+ patch_json = suggestion.code_patch ? JSON.generate(suggestion.code_patch) : "none"
140
+
141
+ <<~PROMPT
142
+ Exception: #{exception.class.name}
143
+ Message: #{exception.message}
144
+ Location: #{suggestion.location}
145
+ Root cause: #{suggestion.root_cause}
146
+ Suggested fixes: #{suggestion.fixes.join("; ")}
147
+ AI notes: #{suggestion.ai_notes}
148
+ Current code_patch JSON: #{patch_json}
149
+ Current patch preview: #{suggestion.code_fix || "none yet"}
150
+
151
+ Numbered source:
152
+ #{source_block}
153
+ PROMPT
154
+ end
155
+
156
+ def self.extract_json(response)
157
+ AiSupport.extract_json(response)
158
+ end
159
+
160
+ def self.build_client
161
+ AiSupport.build_client(Bugsage.configuration)
162
+ end
163
+
164
+ def self.normalize_history(history)
165
+ Array(history).filter_map do |entry|
166
+ role = entry["role"].to_s
167
+ content = entry["content"].to_s.strip
168
+ next if content.empty?
169
+ next unless %w[user assistant].include?(role)
170
+
171
+ { "role" => role, "content" => content }
172
+ end
173
+ end
174
+
175
+ def self.render_widget(suffix:, bug_index: nil)
176
+ index_attr = bug_index.nil? ? "" : %( data-bug-index="#{bug_index}")
177
+
178
+ <<~HTML
179
+ <div class="ai-loading-overlay hidden" id="bugsage-ai-loading#{suffix}" aria-hidden="true">
180
+ <div class="ai-loading-card">
181
+ <div class="ai-spinner" aria-hidden="true"></div>
182
+ <p class="ai-loading-title">#{CodeContext.escape_html(Bugsage.t("ui.ai_chat.working_title"))}</p>
183
+ <p class="ai-loading-step" id="bugsage-ai-loading-step#{suffix}">#{CodeContext.escape_html(Bugsage.t("ui.ai_chat.reading_source"))}</p>
184
+ <div class="ai-loading-bar"><span class="ai-loading-bar-fill"></span></div>
185
+ </div>
186
+ </div>
187
+
188
+ <div class="ai-chat-panel hidden" id="bugsage-ai-chat#{suffix}"#{index_attr}>
189
+ <div class="ai-chat-header">
190
+ <span>#{CodeContext.escape_html(Bugsage.t("ui.ai_chat.chat_header"))}</span>
191
+ <button type="button" class="ai-chat-close" aria-label="#{CodeContext.escape_html(Bugsage.t("ui.ai_chat.close_chat_aria"))}">&times;</button>
192
+ </div>
193
+ <div class="ai-chat-messages" id="bugsage-ai-chat-messages#{suffix}" aria-live="polite"></div>
194
+ <form class="ai-chat-form bugsage-ai-chat-form" data-output-target="#bugsage-ai-chat-messages#{suffix}">
195
+ <input
196
+ class="ai-chat-input"
197
+ type="text"
198
+ name="message"
199
+ autocomplete="off"
200
+ placeholder="#{CodeContext.escape_html(Bugsage.t("ui.ai_chat.input_placeholder"))}"
201
+ />
202
+ <button type="submit" class="ai-chat-send">#{CodeContext.escape_html(Bugsage.t("ui.ai_chat.send"))}</button>
203
+ </form>
204
+ </div>
205
+ HTML
206
+ end
207
+
208
+ def self.styles
209
+ <<~CSS
210
+ .ai-panel.is-loading {
211
+ overflow: hidden;
212
+ }
213
+ .ai-panel-header-actions {
214
+ display: inline-flex;
215
+ align-items: center;
216
+ gap: 8px;
217
+ }
218
+ .ai-chat-toggle {
219
+ background: rgba(137, 180, 250, 0.15);
220
+ color: #89b4fa;
221
+ border: 1px solid rgba(137, 180, 250, 0.35);
222
+ border-radius: 999px;
223
+ width: 36px;
224
+ height: 36px;
225
+ font-size: 16px;
226
+ cursor: pointer;
227
+ }
228
+ .ai-chat-toggle:hover {
229
+ background: rgba(137, 180, 250, 0.25);
230
+ }
231
+ .ai-loading-overlay {
232
+ position: absolute;
233
+ inset: 0;
234
+ background: rgba(30, 30, 46, 0.82);
235
+ border-radius: 8px;
236
+ display: flex;
237
+ align-items: center;
238
+ justify-content: center;
239
+ z-index: 2;
240
+ }
241
+ .ai-loading-overlay.hidden {
242
+ display: none;
243
+ }
244
+ .ai-loading-card {
245
+ text-align: center;
246
+ padding: 20px;
247
+ width: min(100%, 280px);
248
+ }
249
+ .ai-spinner {
250
+ width: 42px;
251
+ height: 42px;
252
+ border: 3px solid rgba(137, 180, 250, 0.2);
253
+ border-top-color: #89b4fa;
254
+ border-radius: 50%;
255
+ margin: 0 auto 14px;
256
+ animation: bugsage-spin 0.9s linear infinite;
257
+ }
258
+ .ai-loading-title {
259
+ color: #cdd6f4;
260
+ font-size: 14px;
261
+ font-weight: bold;
262
+ margin: 0 0 6px 0;
263
+ }
264
+ .ai-loading-step {
265
+ color: #f9e2af;
266
+ font-size: 12px;
267
+ margin: 0 0 12px 0;
268
+ min-height: 16px;
269
+ }
270
+ .ai-loading-bar {
271
+ height: 4px;
272
+ background: rgba(137, 180, 250, 0.15);
273
+ border-radius: 999px;
274
+ overflow: hidden;
275
+ }
276
+ .ai-loading-bar-fill {
277
+ display: block;
278
+ height: 100%;
279
+ width: 35%;
280
+ background: linear-gradient(90deg, #89b4fa, #a6e3a1);
281
+ border-radius: 999px;
282
+ animation: bugsage-progress 1.4s ease-in-out infinite;
283
+ }
284
+ @keyframes bugsage-spin {
285
+ to { transform: rotate(360deg); }
286
+ }
287
+ @keyframes bugsage-progress {
288
+ 0% { transform: translateX(-120%); }
289
+ 100% { transform: translateX(320%); }
290
+ }
291
+ .ai-chat-panel {
292
+ margin-top: 14px;
293
+ background: #1e1e2e;
294
+ border: 1px solid #45475a;
295
+ border-radius: 8px;
296
+ overflow: hidden;
297
+ }
298
+ .ai-chat-panel.hidden {
299
+ display: none;
300
+ }
301
+ .ai-chat-header {
302
+ display: flex;
303
+ align-items: center;
304
+ justify-content: space-between;
305
+ padding: 10px 12px;
306
+ background: #313244;
307
+ color: #cdd6f4;
308
+ font-size: 13px;
309
+ font-weight: bold;
310
+ }
311
+ .ai-chat-close {
312
+ background: transparent;
313
+ border: 0;
314
+ color: #a6adc8;
315
+ font-size: 20px;
316
+ cursor: pointer;
317
+ line-height: 1;
318
+ }
319
+ .ai-chat-messages {
320
+ max-height: 220px;
321
+ overflow-y: auto;
322
+ padding: 12px;
323
+ display: flex;
324
+ flex-direction: column;
325
+ gap: 10px;
326
+ }
327
+ .ai-chat-bubble {
328
+ max-width: 92%;
329
+ padding: 10px 12px;
330
+ border-radius: 10px;
331
+ font-size: 13px;
332
+ line-height: 1.45;
333
+ white-space: pre-wrap;
334
+ }
335
+ .ai-chat-bubble.user {
336
+ align-self: flex-end;
337
+ background: rgba(137, 180, 250, 0.18);
338
+ color: #cdd6f4;
339
+ }
340
+ .ai-chat-bubble.assistant {
341
+ align-self: flex-start;
342
+ background: rgba(166, 227, 161, 0.12);
343
+ color: #cdd6f4;
344
+ }
345
+ .ai-chat-bubble.loading {
346
+ align-self: flex-start;
347
+ color: #f9e2af;
348
+ font-style: italic;
349
+ }
350
+ .ai-chat-form {
351
+ display: flex;
352
+ gap: 8px;
353
+ padding: 12px;
354
+ border-top: 1px solid #45475a;
355
+ }
356
+ .ai-chat-input {
357
+ flex: 1;
358
+ background: #313244;
359
+ color: #cdd6f4;
360
+ border: 1px solid #45475a;
361
+ border-radius: 6px;
362
+ padding: 10px 12px;
363
+ font-family: inherit;
364
+ font-size: 13px;
365
+ }
366
+ .ai-chat-send {
367
+ background: #89b4fa;
368
+ color: #1e1e2e;
369
+ border: 0;
370
+ border-radius: 6px;
371
+ padding: 10px 14px;
372
+ font-family: inherit;
373
+ font-size: 13px;
374
+ font-weight: bold;
375
+ cursor: pointer;
376
+ }
377
+ .ai-chat-send:disabled {
378
+ opacity: 0.5;
379
+ cursor: not-allowed;
380
+ }
381
+ CSS
382
+ end
383
+
384
+ def self.log_failure(error)
385
+ AiSupport.log_failure("AI chat failed", error)
386
+ end
387
+ end
388
+ end