From d26439fb7b558c0f934f1715c73c68098118135c Mon Sep 17 00:00:00 2001 From: Ricky Chilcott Date: Fri, 31 Jul 2026 09:14:07 -0400 Subject: [PATCH] Add "Copy for LLM" button for error occurrences Adds `GET /errors/:error_id/occurrences/:id.md`, which renders a single occurrence as an LLM-ready prompt: the fault in XML-ish tags (class, message, backtrace, source context for application frames, request context) plus instructions to investigate and fix it. Served as text/plain so browsers display it inline rather than downloading it, and rendered through the `text` format so ActionView's ERB handler skips HTML escaping. The buttons are anchors, not buttons: the click handler only calls preventDefault when it can actually reach the clipboard, so non-secure origins, JS failures, and cmd-click all fall through to the readable page. One appears in the error action bar (newest occurrence) and one in each occurrence's summary. Mounts the engine in the dummy app so request tests are possible. Co-Authored-By: Bruno Valentino Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../solid_errors/occurrences_controller.rb | 18 ++++++++ .../layouts/solid_errors/application.html.erb | 23 ++++++++++ .../solid_errors/errors/_actions.html.erb | 4 ++ .../solid_errors/errors/_copy_button.html.erb | 10 +++++ .../occurrences/_occurrence.html.erb | 11 +++-- .../solid_errors/occurrences/show.text.erb | 40 +++++++++++++++++ config/routes.rb | 4 +- lib/solid_errors/engine.rb | 4 ++ test/dummy/config/routes.rb | 2 + .../integration/solid_errors/markdown_test.rb | 45 +++++++++++++++++++ 11 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 app/controllers/solid_errors/occurrences_controller.rb create mode 100644 app/views/solid_errors/errors/_copy_button.html.erb create mode 100644 app/views/solid_errors/occurrences/show.text.erb create mode 100644 test/integration/solid_errors/markdown_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 960ff5c..9cd9946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] +- Add a "Copy for LLM" button that copies an occurrence as an LLM-ready prompt, served from `/errors/:error_id/occurrences/:id.md` ([@bvalentino](https://github.com/fractaledmind/solid_errors/pull/97)) - Fix `undefined method 'bootstrap_svg'` crash on the error page when an error has no occurrences ## [0.7.0] - 2025-06-11 diff --git a/app/controllers/solid_errors/occurrences_controller.rb b/app/controllers/solid_errors/occurrences_controller.rb new file mode 100644 index 0000000..82a705d --- /dev/null +++ b/app/controllers/solid_errors/occurrences_controller.rb @@ -0,0 +1,18 @@ +module SolidErrors + class OccurrencesController < ApplicationController + # GET /errors/1/occurrences/1 + # + # Always renders the markdown prompt, whether or not the URL carried the + # `.md` extension. Served as text/plain so browsers display it inline + # instead of downloading it. + def show + @occurrence = Occurrence.find_by!(id: params[:id], error_id: params[:error_id]) + @error = @occurrence.error + + # Rendered as `text` rather than `md` so ActionView's ERB handler skips + # HTML escaping (its escape_ignore_list covers text/plain), which would + # otherwise mangle backtraces and exception messages. + render "show", formats: [:text], content_type: "text/plain", layout: false + end + end +end diff --git a/app/views/layouts/solid_errors/application.html.erb b/app/views/layouts/solid_errors/application.html.erb index 1c353bc..310a61a 100644 --- a/app/views/layouts/solid_errors/application.html.erb +++ b/app/views/layouts/solid_errors/application.html.erb @@ -51,6 +51,29 @@ document.querySelectorAll('[data-controller="fade"]').forEach(element => { fadeOut(element); }); + + // Copy links fetch their own href and put the body on the clipboard. If the + // clipboard is unavailable (non-secure origin) we never preventDefault, so + // the browser follows the link to the plain text instead. + document.addEventListener('click', event => { + const link = event.target.closest('[data-copy]') + if (!link || !navigator.clipboard) return + + event.preventDefault() + event.stopPropagation() + + const label = link.querySelector('span') + const original = label.textContent + + fetch(link.href) + .then(response => response.text()) + .then(text => navigator.clipboard.writeText(text)) + .then(() => { + label.textContent = 'Copied!' + setTimeout(() => { label.textContent = original }, 2000) + }) + .catch(() => { window.location = link.href }) + }); diff --git a/app/views/solid_errors/errors/_actions.html.erb b/app/views/solid_errors/errors/_actions.html.erb index da60125..821de9e 100644 --- a/app/views/solid_errors/errors/_actions.html.erb +++ b/app/views/solid_errors/errors/_actions.html.erb @@ -5,6 +5,10 @@ Back to errors <% end %> + <% if (latest_occurrence = error.occurrences.order(created_at: :desc).first) %> + <%= render "solid_errors/errors/copy_button", + url: error_occurrence_path(error, latest_occurrence) %> + <% end %> <% if error.resolved? %> <%= render 'solid_errors/errors/delete_button', error: error %> <% else %> diff --git a/app/views/solid_errors/errors/_copy_button.html.erb b/app/views/solid_errors/errors/_copy_button.html.erb new file mode 100644 index 0000000..ec9afe2 --- /dev/null +++ b/app/views/solid_errors/errors/_copy_button.html.erb @@ -0,0 +1,10 @@ +<%# locals: (url:, classes: "inline-flex items-center justify-center gap-2 font-medium cursor-pointer border rounded-lg py-3 px-5 bg-gray-100 text-initial border-gray-300 hover:ring-gray-200 hover:ring-8") -%> +<%# Deliberately an : when the clipboard API is unavailable (any non-secure + origin) or JS fails, the browser just follows the link to the plain text. -%> +<%= link_to url, class: classes, data: {copy: true} do %> + + + + + Copy for LLM +<% end %> diff --git a/app/views/solid_errors/occurrences/_occurrence.html.erb b/app/views/solid_errors/occurrences/_occurrence.html.erb index 722da96..074ac97 100644 --- a/app/views/solid_errors/occurrences/_occurrence.html.erb +++ b/app/views/solid_errors/occurrences/_occurrence.html.erb @@ -4,9 +4,14 @@ <%= tag.section id: dom_id(occurrence), class: "" do %> <%= tag.details open: open do %> - - <%= time_tag seen_at, seen_at %> - (<%= time_ago_in_words(seen_at) %> ago) + + + <%= time_tag seen_at, seen_at %> + (<%= time_ago_in_words(seen_at) %> ago) + + <%= render "solid_errors/errors/copy_button", + url: error_occurrence_path(occurrence.error_id, occurrence), + classes: "inline-flex items-center gap-1 text-sm font-medium cursor-pointer border rounded-lg py-1 px-2 bg-gray-100 text-initial border-gray-300 hover:ring-gray-200 hover:ring-4" %>
diff --git a/app/views/solid_errors/occurrences/show.text.erb b/app/views/solid_errors/occurrences/show.text.erb new file mode 100644 index 0000000..4130c66 --- /dev/null +++ b/app/views/solid_errors/occurrences/show.text.erb @@ -0,0 +1,40 @@ +<%- backtrace = @occurrence.parsed_backtrace -%> +Fix this error from the <%= Rails.application.class.module_parent_name %> application, reported by Solid Errors: + + +<%= @error.exception_class %> +<%= @error.message %> +<%= @error.severity %> +<%= @error.source %> +<%= @occurrence.created_at.utc.iso8601 %> +<%= @error.occurrences.count %> +<%= SolidErrors::BacktraceLine::RAILS_ROOT %> + +<%- backtrace.lines.each do |line| -%> +<%= line.filtered_method ? line.to_s : line.unparsed_line %> +<%- end -%> + +<%- if backtrace.application_lines.any? -%> + +<%- backtrace.application_lines.each do |line| -%> + +<%- line.source.each do |number, code| -%> +<%= number %>: <%= code.chomp %> +<%- end -%> + +<%- end -%> + +<%- end -%> +<%- if @occurrence.context.present? -%> + +<%= JSON.pretty_generate(@occurrence.context) %> + +<%- end -%> + + +Investigate and fix the root cause of this error: + +1. Use the backtrace and source context to locate where the error was raised in this codebase. +2. Check the request context for the conditions that triggered it. +3. Implement a fix that addresses the root cause, following the conventions of the surrounding code. +4. Add or update tests covering the failure case where applicable. diff --git a/config/routes.rb b/config/routes.rb index 726e38f..2f70803 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,7 @@ SolidErrors::Engine.routes.draw do get "/", to: "errors#index", as: :root - resources :errors, only: [:index, :show, :update, :destroy], path: "" + resources :errors, only: [:index, :show, :update, :destroy], path: "" do + resources :occurrences, only: [:show] + end end diff --git a/lib/solid_errors/engine.rb b/lib/solid_errors/engine.rb index 1a92aa5..f32fe40 100644 --- a/lib/solid_errors/engine.rb +++ b/lib/solid_errors/engine.rb @@ -17,6 +17,10 @@ class Engine < ::Rails::Engine end end + initializer "solid_errors.mime_types" do + Mime::Type.register "text/markdown", :md unless Mime[:md] + end + initializer "solid_errors.active_record.error_subscriber" do Rails.error.subscribe(SolidErrors::Subscriber.new) end diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb index 9f768e5..9816d98 100644 --- a/test/dummy/config/routes.rb +++ b/test/dummy/config/routes.rb @@ -7,4 +7,6 @@ # Defines the root path route ("/") # root "posts#index" + + mount SolidErrors::Engine => "/solid_errors" end diff --git a/test/integration/solid_errors/markdown_test.rb b/test/integration/solid_errors/markdown_test.rb new file mode 100644 index 0000000..85f9b28 --- /dev/null +++ b/test/integration/solid_errors/markdown_test.rb @@ -0,0 +1,45 @@ +require "test_helper" + +class SolidErrors::MarkdownTest < ActionDispatch::IntegrationTest + setup do + begin + raise StandardError, "Something wrong" + rescue => exception + Rails.error.report(exception, context: {controller: "UsersController", action: "show"}) + end + + @error = SolidErrors::Error.last + @occurrence = @error.occurrences.last + end + + test "renders the occurrence as a plain text prompt" do + get "/solid_errors/#{@error.id}/occurrences/#{@occurrence.id}.md" + + assert_response :success + assert_equal "text/plain", response.media_type + + assert_includes response.body, "Investigate and fix the root cause of this error:" + assert_includes response.body, "StandardError" + assert_includes response.body, "" + assert_includes response.body, "\"controller\": \"UsersController\"" + # Not HTML escaped: the exception message survives verbatim. + assert_includes response.body, "Something wrong" + end + + test "404s when the occurrence does not belong to the error" do + other = SolidErrors::Error.create!(exception_class: "Other", message: "other", + severity: "error", fingerprint: "other") + + get "/solid_errors/#{other.id}/occurrences/#{@occurrence.id}.md" + + assert_response :not_found + end + + test "the error page links to the copy prompt" do + get "/solid_errors/#{@error.id}" + + assert_response :success + assert_includes response.body, "Copy for LLM" + assert_includes response.body, "/solid_errors/#{@error.id}/occurrences/#{@occurrence.id}" + end +end