activeadmin_annotations
Collect span-level text highlights and comments inside a bounded ActiveAdmin block.
The gem stays domain-agnostic: the host app chooses what review context means, which optional categories to offer, where to render the panel, and whether annotations are pinned to content revisions.
Requirements
- Ruby 3.2+
- Rails 7.1+
- ActiveAdmin 3.0+
- PostgreSQL recommended for production (SQLite works for development; some metadata filters use Postgres JSON operators)
Host app setup
Subject models and the reviewer model should use UUID primary keys. Migrations store subject_id and reviewer_id as UUIDs.
Set the reviewer model in the initializer:
ActiveAdmin::Annotations.reviewer_class_name = "User"
The reviewer must be available as current_user (or your ActiveAdmin authentication helper) when the panel renders.
The Review admin menu item is visible when review_menu_visible returns true for the signed-in user. Default checks user.administrator?. Override in the initializer:
ActiveAdmin::Annotations. = ->(user) { user&.can_review_annotations? }
Installation
Add to your Gemfile:
gem "activeadmin_annotations"
Then:
bundle install
rails activeadmin_annotations:install:migrations
rails db:migrate
Configure the reviewer model and optional categories:
# config/initializers/activeadmin_annotations.rb
ActiveAdmin::Annotations.reviewer_class_name = "User"
ActiveAdmin::Annotations. = ->(user) { user&.administrator? }
ActiveAdmin::Annotations.category_label = "Category"
ActiveAdmin::Annotations.categories = {
"needs_follow_up" => "Needs follow-up"
}
ActiveAdmin::Annotations.copy_instructions = <<~TEXT
These annotations capture span-level review notes for evaluation datasets.
TEXT
copy_instructions can be a string or a callable (annotation, review) -> string.
It is prepended to the text copied from the annotation show page.
Host model
class Article < ApplicationRecord
include ActiveAdmin::Annotations::Annotatable
end
ActiveAdmin panel (basic, no content revisions)
When the subject does not use content revisions, render the panel directly:
text_node helpers.activeadmin_annotations_panel(
subject: resource,
field: :body,
context: {title: resource.title, author: resource.},
context_panels: [
{title: "Metadata", content: JSON.pretty_generate(resource.)}
],
content: markdown(resource.body)
)
The gem stores annotations against the live subject text. If the reviewed text changes, stale detection uses context_digest only. No version UI is shown.
Content revisions (optional)
Annotations use character offsets. If reviewed text can change in place, pin annotations to a content revision.
The gem does not depend on active_version. Revisions are optional and disabled unless:
content_revision_strategyis:active_versionor:auto(default), and the subject model useshas_revisionsfrom active_version, or- you provide custom revision resolvers (below).
Using active_version (recommended when content changes)
active_version is not a gem dependency. When present on a subject, the gem follows this convention:
subject.class.has_revisions?is truesubject.current_versionreturns the head revision numbersubject.at_version(number)returns a read-only copy at that revision
Pinned version N on the head H resolves content as:
N == H→ livesubjectN < H→subject.at_version(N + 1)(active_version stores the previous text in the next revision row)
Setup in the host app:
rails g active_version:revisions Article
rails db:migrate
class Article < ApplicationRecord
include ActiveAdmin::Annotations::Annotatable
has_revisions(only: %i[title body])
end
Then either render the panel through ActiveAdmin::Annotations::ReviewPanel (handles pinning, stale state, and version filtering):
text_node ActiveAdmin::Annotations::ReviewPanel.render(
subject: resource,
reviewer: current_user,
field: :body,
view_context: self,
context_builder: ->(content_subject) { {title: content_subject.title} },
content_builder: ->(content_subject) { markdown(content_subject.body) },
advance_review_url: advance_annotation_review_admin_article_path(resource)
)
Wire the stale-banner action on the host subject resource:
# app/admin/articles.rb
ActiveAdmin.register Article do
member_action :advance_annotation_review, method: :post do
context = {title: resource.title, body: resource.body}
ActiveAdmin::Annotations::ReviewService.advance_to_latest!(
subject: resource,
reviewer: current_user,
context: context,
latest_content_revision_version: resource.current_version
)
redirect_to resource_path, notice: "Review moved to the latest content version."
end
end
Pass the same path helper to ReviewPanel.render as advance_review_url. The panel shows the button only when the review is stale and a URL is present.
Or pass revision options manually to activeadmin_annotations_panel.
Enable active_version detection explicitly:
ActiveAdmin::Annotations.content_revision_strategy = :active_version
Custom revision backend (no active_version)
Disable auto-detection and provide callables (use :auto, not :none):
ActiveAdmin::Annotations.content_revision_strategy = :auto
ActiveAdmin::Annotations.current_content_revision_version = lambda do |subject|
subject.published_version
end
ActiveAdmin::Annotations.content_subject_for_revision = lambda do |subject, version|
subject.versions.find_by!(number: version)
end
With custom resolvers, revision mode is enabled even without active_version.
Revision configuration reference
content_revision_strategy—:auto(default),:active_version, or:none/falsecurrent_content_revision_version—(subject) -> Integeroverride for head versioncontent_subject_for_revision—(subject, version) -> Objectoverride for rendered contentcontent_revision_label—false, format string ("Draft %{version}"), or(version) -> Stringstale_content_review_message— string (%{pinned_version},%{latest_version}) or(review, pinned_version:, latest_version:)stale_context_message— string or(review)when revisions are off butcontext_digestchanged
Disable all revision UI and logic:
ActiveAdmin::Annotations.content_revision_strategy = :none
Storage
ActiveAdmin::Annotations::Review— one row per subject + reviewer, with frozencontext_jsonActiveAdmin::Annotations::Annotation— span highlights tied to a review and field namecontent_revision_version— optional pin when revision mode is enabled (defaults to0)
Export
ActiveAdmin registers review and span resources. Use the Export JSONL action on the reviews index for downstream tooling.
Theme customization
The panel loads activeadmin_annotations.css and uses stable aa-annotations-* class names. Action buttons use ActiveAdmin's action-item-button so they inherit your admin chrome; colors, spacing, borders, and text highlights are overridden in the host app.
Override styles
Add a host stylesheet that loads after the gem asset (for example in your ActiveAdmin layout or app/assets/stylesheets/active_admin/):
/* app/assets/stylesheets/active_admin/custom_annotations.css */
.aa-annotations-sidebar {
border-color: var(--border-color, #e5e7eb);
}
.aa-annotations-save-button {
background-color: var(--primary, #2563eb);
border-color: var(--primary, #2563eb);
}
::highlight(aa-annotations-saved) {
background-color: color-mix(in srgb, var(--primary, #2563eb) 30%, #fef08a);
}
::highlight(aa-annotations-pending) {
background-color: color-mix(in srgb, var(--primary, #2563eb) 40%, #fef08a);
text-decoration-color: var(--primary, #2563eb);
}
Gem defaults include .dark .aa-annotations-* rules for ActiveAdmin 4 dark mode. Mirror host overrides under .dark when you change light-mode colors.
Main layout hooks: .aa-annotations-panel, .aa-annotations-layout, .aa-annotations-sidebar, .aa-annotations-annotatable, .aa-annotations-composer, .aa-annotations-stale-banner.
Override partials
Copy views from the gem into app/views/active_admin/annotations/:
_panel.html.erb— review UI and composer_read_only_content.html.erb— content-only fallback when no reviewer is signed in_copy_details_action.html.erb— copy action on the span show page
Override _panel.html.erb when you need different markup or to pin a custom Stimulus controller on the annotator root.
Authorization
The gem registers ActiveAdmin resources for reviews and spans. Member actions use your host authorization adapter.
Span create, update, destroy, and copy actions verify that the signed-in reviewer owns the target review. Host policies should still gate who may access annotation resources at all (create?, update?, destroy?, show?).
With ActionPolicy (or ActiveAdmin's ActionPolicy adapter), allow the span Copy details action explicitly:
# app/policies/active_admin/annotations/annotation_policy.rb
class ActiveAdmin::Annotations::AnnotationPolicy < ApplicationPolicy
def copy_text?
show?
end
end
Review resources use the usual CRUD predicates (index?, show?, create?, update?) from your app policy base class.
Place policies under app/policies/active_admin/annotations/ so Zeitwerk maps them to ActiveAdmin::Annotations::*Policy.
Dependencies
Required:
- Rails >= 7.1
- ActiveAdmin >= 3.0
Optional (host app):
active_versionfor revision snapshots — not bundled; follow active_version setup when content can regenerate
Development
From the gem root:
bundle install
bundle exec appraisal install
bundle exec rubocop
bundle exec polyrun parallel-rspec --workers 5 --merge-failures
Matrixed Rails versions use Appraisal: gemfiles/rails72.gemfile, rails8ruby34.gemfile, and rails8truffleruby.gemfile. Run bundle exec appraisal rspec to execute RSpec in each gemfile context.
Shared agent guidance is managed with pray via Prayfile.
Trunk config lives in .trunk/; CI can run trunk via .github/workflows/_trunk_check.yml. Releases: make release or usr/bin/release.rb.
Contributing
See CONTRIBUTING.md. Bug reports and pull requests are welcome at https://github.com/amkisko/activeadmin_annotations.rb/issues
License
MIT — see LICENSE.md.
Sponsors
Sponsored by Kisko Labs.