coplan-engine 0.1.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 (84) hide show
  1. checksums.yaml +7 -0
  2. data/app/assets/stylesheets/coplan/application.css +956 -0
  3. data/app/controllers/coplan/api/v1/base_controller.rb +75 -0
  4. data/app/controllers/coplan/api/v1/comments_controller.rb +135 -0
  5. data/app/controllers/coplan/api/v1/leases_controller.rb +62 -0
  6. data/app/controllers/coplan/api/v1/operations_controller.rb +298 -0
  7. data/app/controllers/coplan/api/v1/plans_controller.rb +129 -0
  8. data/app/controllers/coplan/api/v1/sessions_controller.rb +92 -0
  9. data/app/controllers/coplan/application_controller.rb +78 -0
  10. data/app/controllers/coplan/automated_reviews_controller.rb +29 -0
  11. data/app/controllers/coplan/comment_threads_controller.rb +148 -0
  12. data/app/controllers/coplan/comments_controller.rb +40 -0
  13. data/app/controllers/coplan/dashboard_controller.rb +6 -0
  14. data/app/controllers/coplan/plan_versions_controller.rb +29 -0
  15. data/app/controllers/coplan/plans_controller.rb +53 -0
  16. data/app/controllers/coplan/settings/tokens_controller.rb +37 -0
  17. data/app/helpers/coplan/application_helper.rb +4 -0
  18. data/app/helpers/coplan/comments_helper.rb +20 -0
  19. data/app/helpers/coplan/markdown_helper.rb +36 -0
  20. data/app/javascript/controllers/coplan/dropdown_controller.js +25 -0
  21. data/app/javascript/controllers/coplan/line_selection_controller.js +112 -0
  22. data/app/javascript/controllers/coplan/tabs_controller.js +18 -0
  23. data/app/javascript/controllers/coplan/text_selection_controller.js +376 -0
  24. data/app/jobs/coplan/application_job.rb +4 -0
  25. data/app/jobs/coplan/automated_review_job.rb +71 -0
  26. data/app/jobs/coplan/commit_expired_session_job.rb +29 -0
  27. data/app/jobs/coplan/notification_job.rb +10 -0
  28. data/app/models/coplan/api_token.rb +50 -0
  29. data/app/models/coplan/application_record.rb +14 -0
  30. data/app/models/coplan/automated_plan_reviewer.rb +62 -0
  31. data/app/models/coplan/comment.rb +24 -0
  32. data/app/models/coplan/comment_thread.rb +151 -0
  33. data/app/models/coplan/current.rb +5 -0
  34. data/app/models/coplan/edit_lease.rb +57 -0
  35. data/app/models/coplan/edit_session.rb +61 -0
  36. data/app/models/coplan/plan.rb +28 -0
  37. data/app/models/coplan/plan_collaborator.rb +12 -0
  38. data/app/models/coplan/plan_version.rb +23 -0
  39. data/app/models/coplan/user.rb +20 -0
  40. data/app/policies/coplan/application_policy.rb +14 -0
  41. data/app/policies/coplan/comment_thread_policy.rb +23 -0
  42. data/app/policies/coplan/plan_policy.rb +15 -0
  43. data/app/services/coplan/ai_providers/anthropic.rb +21 -0
  44. data/app/services/coplan/ai_providers/open_ai.rb +44 -0
  45. data/app/services/coplan/broadcaster.rb +32 -0
  46. data/app/services/coplan/plans/apply_operations.rb +128 -0
  47. data/app/services/coplan/plans/commit_session.rb +186 -0
  48. data/app/services/coplan/plans/create.rb +30 -0
  49. data/app/services/coplan/plans/operation_error.rb +5 -0
  50. data/app/services/coplan/plans/position_resolver.rb +191 -0
  51. data/app/services/coplan/plans/review_prompt_formatter.rb +25 -0
  52. data/app/services/coplan/plans/review_response_parser.rb +65 -0
  53. data/app/services/coplan/plans/transform_range.rb +99 -0
  54. data/app/services/coplan/plans/trigger_automated_reviews.rb +33 -0
  55. data/app/views/coplan/comment_threads/_new_comment_form.html.erb +17 -0
  56. data/app/views/coplan/comment_threads/_reply_form.html.erb +8 -0
  57. data/app/views/coplan/comment_threads/_thread.html.erb +47 -0
  58. data/app/views/coplan/comments/_comment.html.erb +9 -0
  59. data/app/views/coplan/dashboard/show.html.erb +8 -0
  60. data/app/views/coplan/plan_versions/index.html.erb +21 -0
  61. data/app/views/coplan/plan_versions/show.html.erb +29 -0
  62. data/app/views/coplan/plans/_header.html.erb +26 -0
  63. data/app/views/coplan/plans/edit.html.erb +15 -0
  64. data/app/views/coplan/plans/index.html.erb +35 -0
  65. data/app/views/coplan/plans/show.html.erb +56 -0
  66. data/app/views/coplan/settings/tokens/_form.html.erb +8 -0
  67. data/app/views/coplan/settings/tokens/_token_reveal.html.erb +7 -0
  68. data/app/views/coplan/settings/tokens/_token_row.html.erb +24 -0
  69. data/app/views/coplan/settings/tokens/create.turbo_stream.erb +11 -0
  70. data/app/views/coplan/settings/tokens/destroy.turbo_stream.erb +5 -0
  71. data/app/views/coplan/settings/tokens/index.html.erb +32 -0
  72. data/app/views/layouts/coplan/application.html.erb +42 -0
  73. data/config/importmap.rb +1 -0
  74. data/config/routes.rb +41 -0
  75. data/db/migrate/20260226200000_create_coplan_schema.rb +173 -0
  76. data/lib/coplan/configuration.rb +17 -0
  77. data/lib/coplan/engine.rb +34 -0
  78. data/lib/coplan/version.rb +3 -0
  79. data/lib/coplan.rb +15 -0
  80. data/lib/tasks/coplan.rake +4 -0
  81. data/prompts/reviewers/routing.md +14 -0
  82. data/prompts/reviewers/scalability.md +14 -0
  83. data/prompts/reviewers/security.md +14 -0
  84. metadata +245 -0
@@ -0,0 +1,99 @@
1
+ module CoPlan
2
+ module Plans
3
+ # Operational Transform engine for character-range transformations.
4
+ #
5
+ # INVARIANT: Every PlanVersion's operations_json entries MUST contain
6
+ # positional metadata (resolved_range + new_range/delta, or replacements).
7
+ # All content edits flow through Plans::ApplyOperations which stores this
8
+ # data. There are no legacy or non-positional edit paths.
9
+ #
10
+ # If an operation lacks positional metadata, transform_through_versions
11
+ # raises Conflict rather than silently skipping it.
12
+ class TransformRange
13
+ class Conflict < StandardError; end
14
+
15
+ # Transform a range [s, e] through an edit that replaced [s2, e2] with text of length new_length.
16
+ # Returns the transformed [s, e] or raises Conflict if ranges overlap.
17
+ #
18
+ # edit_data is a hash with:
19
+ # resolved_range: [s2, e2] — the range that was replaced
20
+ # delta: integer — the net character change (new_length - old_length)
21
+ # or new_range: [s2, s2 + new_length] — can derive delta from this
22
+ def self.transform(range, edit_data)
23
+ s, e = range
24
+ edit_data = edit_data.transform_keys(&:to_s)
25
+
26
+ s2, e2 = edit_data["resolved_range"]
27
+
28
+ # Calculate delta from the edit
29
+ if edit_data.key?("new_range")
30
+ new_s2, new_e2 = edit_data["new_range"]
31
+ delta = (new_e2 - new_s2) - (e2 - s2)
32
+ elsif edit_data.key?("delta")
33
+ delta = edit_data["delta"].to_i
34
+ else
35
+ raise ArgumentError, "edit_data must contain 'new_range' or 'delta'"
36
+ end
37
+
38
+ # Zero-width insert point: special handling
39
+ if s == e
40
+ # Insert point: shift if edit is strictly before
41
+ if e2 <= s
42
+ return [s + delta, e + delta]
43
+ elsif s2 > s
44
+ return [s, e]
45
+ else
46
+ raise Conflict, "Edit overlaps with insert point"
47
+ end
48
+ end
49
+
50
+ # Case 1: Edit is entirely before our range (e2 <= s)
51
+ if e2 <= s
52
+ return [s + delta, e + delta]
53
+ end
54
+
55
+ # Case 2: Edit is entirely after our range (s2 >= e)
56
+ if s2 >= e
57
+ return [s, e]
58
+ end
59
+
60
+ # Case 3: Overlap — conflict
61
+ raise Conflict, "Ranges overlap: [#{s}, #{e}] conflicts with edit at [#{s2}, #{e2}]"
62
+ end
63
+
64
+ # Transform a range through a sequence of edits from a PlanVersion.
65
+ # Each version has operations_json with resolved position data.
66
+ #
67
+ # versions: array of PlanVersion records (or hashes with operations_json),
68
+ # ordered by revision ascending
69
+ # Returns the transformed range or raises Conflict.
70
+ def self.transform_through_versions(range, versions)
71
+ current_range = range.dup
72
+
73
+ versions.each do |version|
74
+ ops = version.is_a?(Hash) ? version[:operations_json] || version["operations_json"] : version.operations_json
75
+ next if ops.blank?
76
+
77
+ ops.each do |op_data|
78
+ op_data = op_data.transform_keys(&:to_s)
79
+
80
+ if op_data.key?("replacements")
81
+ # replace_all: multiple ranges, each shifts independently
82
+ # Process in reverse order (highest position first) since each was
83
+ # already adjusted for previous replacements during application
84
+ op_data["replacements"].sort_by { |r| -r["resolved_range"][0] }.each do |replacement|
85
+ current_range = transform(current_range, replacement)
86
+ end
87
+ elsif op_data.key?("resolved_range")
88
+ current_range = transform(current_range, op_data)
89
+ else
90
+ raise Conflict, "Operation lacks positional metadata (resolved_range or replacements)"
91
+ end
92
+ end
93
+ end
94
+
95
+ current_range
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,33 @@
1
+ module CoPlan
2
+ module Plans
3
+ class TriggerAutomatedReviews
4
+ def self.call(plan:, new_status:, triggered_by:)
5
+ new(plan:, new_status:, triggered_by:).call
6
+ end
7
+
8
+ def initialize(plan:, new_status:, triggered_by:)
9
+ @plan = plan
10
+ @new_status = new_status
11
+ @triggered_by = triggered_by
12
+ end
13
+
14
+ def call
15
+ version_id = @plan.current_plan_version_id
16
+ return unless version_id
17
+
18
+ reviewers = CoPlan::AutomatedPlanReviewer.enabled
19
+
20
+ reviewers.each do |reviewer|
21
+ next unless reviewer.triggers_on_status?(@new_status)
22
+
23
+ AutomatedReviewJob.perform_later(
24
+ plan_id: @plan.id,
25
+ reviewer_id: reviewer.id,
26
+ plan_version_id: version_id,
27
+ triggered_by: @triggered_by
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,17 @@
1
+ <div class="comment-form card" id="new-comment-form" data-coplan--text-selection-target="form" style="display: none;">
2
+ <%= form_with url: plan_comment_threads_path(plan), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetCommentForm" } do |f| %>
3
+ <input type="hidden" name="comment_thread[anchor_text]" data-coplan--text-selection-target="anchorInput" value="">
4
+ <input type="hidden" name="comment_thread[anchor_context]" data-coplan--text-selection-target="contextInput" value="">
5
+ <div class="comment-form__anchor" data-coplan--text-selection-target="anchorPreview" style="display: none;">
6
+ <span class="text-sm text-muted">Commenting on:</span>
7
+ <blockquote class="comment-form__quote" data-coplan--text-selection-target="anchorQuote"></blockquote>
8
+ </div>
9
+ <div class="form-group">
10
+ <textarea name="comment_thread[body_markdown]" id="comment_thread_body_markdown" rows="3" placeholder="Write a comment..." required></textarea>
11
+ </div>
12
+ <div class="comment-form__actions">
13
+ <button type="submit" class="btn btn--primary btn--sm">Comment</button>
14
+ <button type="button" class="btn btn--secondary btn--sm" data-action="coplan--text-selection#cancelComment">Cancel</button>
15
+ </div>
16
+ <% end %>
17
+ </div>
@@ -0,0 +1,8 @@
1
+ <div class="comment-thread__reply" id="<%= dom_id(thread, :reply_form) %>">
2
+ <%= form_with url: plan_comment_thread_comments_path(plan, thread), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetReplyForm" } do |f| %>
3
+ <div class="form-group">
4
+ <textarea name="comment[body_markdown]" rows="2" placeholder="Reply..." required></textarea>
5
+ </div>
6
+ <button type="submit" class="btn btn--secondary btn--sm">Reply</button>
7
+ <% end %>
8
+ </div>
@@ -0,0 +1,47 @@
1
+ <div class="comment-thread card" id="<%= dom_id(thread) %>"
2
+ data-anchor-text="<%= thread.anchor_text %>"
3
+ data-anchor-context="<%= thread.anchor_context %>"
4
+ data-thread-id="<%= thread.id %>">
5
+ <div class="comment-thread__header">
6
+ <div class="comment-thread__meta">
7
+ <span class="badge badge--<%= thread.status == 'open' ? 'live' : 'abandoned' %>"><%= thread.status %></span>
8
+ <% if thread.out_of_date? %>
9
+ <span class="badge badge--abandoned">out of date</span>
10
+ <% end %>
11
+ </div>
12
+ <% if thread.anchored? %>
13
+ <blockquote class="comment-thread__anchor-quote" data-action="click->coplan--text-selection#scrollToAnchor" data-anchor="<%= thread.anchor_text %>" style="cursor: pointer;"><%= thread.anchor_preview %></blockquote>
14
+ <% end %>
15
+ </div>
16
+
17
+ <div class="comment-thread__comments" id="<%= dom_id(thread, :comments) %>">
18
+ <% thread.comments.each do |comment| %>
19
+ <%= render partial: "coplan/comments/comment", locals: { comment: comment } %>
20
+ <% end %>
21
+ </div>
22
+
23
+ <%# current_user may not be available during Turbo Stream broadcasts — show all actions in that case %>
24
+ <% viewer = local_assigns.fetch(:current_user, nil) %>
25
+ <% is_plan_author = viewer.nil? || plan.created_by_user_id == viewer.id %>
26
+ <% is_thread_author = viewer.nil? || thread.created_by_user_id == viewer.id %>
27
+
28
+ <% if thread.status == "open" %>
29
+ <%= render partial: "coplan/comment_threads/reply_form", locals: { thread: thread, plan: plan } %>
30
+
31
+ <div class="comment-thread__actions">
32
+ <% if is_plan_author || is_thread_author %>
33
+ <%= link_to "Resolve", resolve_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
34
+ <% end %>
35
+ <% if is_plan_author %>
36
+ <%= link_to "Accept", accept_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
37
+ <%= link_to "Dismiss", dismiss_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
38
+ <% end %>
39
+ </div>
40
+ <% else %>
41
+ <% if is_plan_author || is_thread_author %>
42
+ <div class="comment-thread__actions">
43
+ <%= link_to "Reopen", reopen_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
44
+ </div>
45
+ <% end %>
46
+ <% end %>
47
+ </div>
@@ -0,0 +1,9 @@
1
+ <div class="comment" id="<%= dom_id(comment) %>">
2
+ <div class="comment__header text-sm text-muted">
3
+ <strong><%= comment_author_name(comment) %></strong>
4
+ · <%= time_ago_in_words(comment.created_at) %> ago
5
+ </div>
6
+ <div class="comment__body">
7
+ <%= render_markdown(comment.body_markdown) %>
8
+ </div>
9
+ </div>
@@ -0,0 +1,8 @@
1
+ <div class="page-header">
2
+ <h1>Dashboard</h1>
3
+ </div>
4
+
5
+ <div class="empty-state card">
6
+ <p>Welcome to <strong>CoPlan</strong>.</p>
7
+ <p class="text-muted text-sm">Plans and collaboration tools will appear here once set up.</p>
8
+ </div>
@@ -0,0 +1,21 @@
1
+ <div class="page-header">
2
+ <h1>Version History: <%= @plan.title %></h1>
3
+ <%= link_to "Back to Plan", plan_path(@plan), class: "btn btn--secondary" %>
4
+ </div>
5
+
6
+ <div class="versions-list">
7
+ <% @versions.each do |version| %>
8
+ <div class="card versions-list__item">
9
+ <div class="versions-list__header">
10
+ <%= link_to "v#{version.revision}", plan_version_path(@plan, version), class: "versions-list__link" %>
11
+ <span class="badge badge--<%= version.actor_type == 'human' ? 'developing' : 'considering' %>"><%= version.actor_type %></span>
12
+ </div>
13
+ <% if version.change_summary.present? %>
14
+ <p class="text-sm"><%= version.change_summary %></p>
15
+ <% end %>
16
+ <div class="text-sm text-muted">
17
+ <%= time_ago_in_words(version.created_at) %> ago
18
+ </div>
19
+ </div>
20
+ <% end %>
21
+ </div>
@@ -0,0 +1,29 @@
1
+ <div class="page-header">
2
+ <div>
3
+ <h1><%= @plan.title %> — v<%= @version.revision %></h1>
4
+ <div class="text-sm text-muted mt-md">
5
+ <%= @version.actor_type %> · <%= time_ago_in_words(@version.created_at) %> ago
6
+ <% if @version.change_summary.present? %>
7
+ · <%= @version.change_summary %>
8
+ <% end %>
9
+ </div>
10
+ </div>
11
+ <div class="page-header__actions">
12
+ <%= link_to "All Versions", plan_versions_path(@plan), class: "btn btn--secondary" %>
13
+ <%= link_to "Current", plan_path(@plan), class: "btn btn--secondary" %>
14
+ </div>
15
+ </div>
16
+
17
+ <% if @diff %>
18
+ <div class="card mb-md">
19
+ <h3>Changes from v<%= @version.revision - 1 %></h3>
20
+ <div class="diff-view">
21
+ <%= @diff.to_s(:html).html_safe %>
22
+ </div>
23
+ </div>
24
+ <% end %>
25
+
26
+ <div class="plan-content card">
27
+ <h3>Full Content</h3>
28
+ <%= render_markdown(@version.content_markdown) %>
29
+ </div>
@@ -0,0 +1,26 @@
1
+ <div class="page-header" id="plan-header">
2
+ <div>
3
+ <h1><%= plan.title %></h1>
4
+ <div class="text-sm text-muted mt-md">
5
+ by <%= plan.created_by_user.name %> · v<%= plan.current_revision %> · <span class="badge badge--<%= plan.status %>"><%= plan.status %></span>
6
+ </div>
7
+ </div>
8
+ <div class="page-header__actions">
9
+ <% if CoPlan::AutomatedPlanReviewer.enabled.any? %>
10
+ <div class="dropdown" data-controller="coplan--dropdown">
11
+ <button class="btn btn--secondary" data-action="coplan--dropdown#toggle">
12
+ 🤖 Run Reviewer ▾
13
+ </button>
14
+ <div class="dropdown__menu" data-coplan--dropdown-target="menu" style="display: none;">
15
+ <% CoPlan::AutomatedPlanReviewer.enabled.order(:name).each do |reviewer| %>
16
+ <%= button_to reviewer.name,
17
+ plan_automated_reviews_path(plan, reviewer_id: reviewer.id),
18
+ method: :post,
19
+ class: "dropdown__item" %>
20
+ <% end %>
21
+ </div>
22
+ </div>
23
+ <% end %>
24
+ <%= link_to "History (v#{plan.current_revision})", plan_versions_path(plan), class: "btn btn--secondary" %>
25
+ </div>
26
+ </div>
@@ -0,0 +1,15 @@
1
+ <div class="page-header">
2
+ <h1>Edit: <%= @plan.title %></h1>
3
+ </div>
4
+
5
+ <%= form_with model: @plan, url: plan_path(@plan), method: :patch, class: "plan-form card" do |f| %>
6
+ <div class="form-group">
7
+ <%= f.label :title %>
8
+ <%= f.text_field :title, required: true %>
9
+ </div>
10
+
11
+ <div class="plan-form__actions">
12
+ <%= f.submit "Save Changes", class: "btn btn--primary" %>
13
+ <%= link_to "Cancel", plan_path(@plan), class: "btn btn--secondary" %>
14
+ </div>
15
+ <% end %>
@@ -0,0 +1,35 @@
1
+ <div class="page-header">
2
+ <h1>Plans</h1>
3
+ </div>
4
+
5
+ <div class="status-filters">
6
+ <%= link_to "All Plans", plans_path(params.permit(:status)), class: "status-filter #{'status-filter--active' if params[:scope].blank?}" %>
7
+ <%= link_to "My Plans", plans_path(params.permit(:status).merge(scope: "mine")), class: "status-filter #{'status-filter--active' if params[:scope] == 'mine'}" %>
8
+ </div>
9
+
10
+ <div class="status-filters">
11
+ <%= link_to "All", plans_path(params.permit(:scope)), class: "status-filter #{'status-filter--active' if params[:status].blank?}" %>
12
+ <% CoPlan::Plan::STATUSES.each do |status| %>
13
+ <%= link_to status.titleize, plans_path(params.permit(:scope).merge(status: status)), class: "status-filter status-filter--#{status} #{'status-filter--active' if params[:status] == status}" %>
14
+ <% end %>
15
+ </div>
16
+
17
+ <% if @plans.any? %>
18
+ <div class="plans-list">
19
+ <% @plans.each do |plan| %>
20
+ <div class="card plans-list__item">
21
+ <div class="plans-list__header">
22
+ <%= link_to plan.title, plan_path(plan), class: "plans-list__title" %>
23
+ <span class="badge badge--<%= plan.status %>"><%= plan.status %></span>
24
+ </div>
25
+ <div class="plans-list__meta text-sm text-muted">
26
+ by <%= plan.created_by_user.name %> · v<%= plan.current_revision %> · updated <%= time_ago_in_words(plan.updated_at) %> ago
27
+ </div>
28
+ </div>
29
+ <% end %>
30
+ </div>
31
+ <% else %>
32
+ <div class="empty-state card">
33
+ <p>No plans yet. Plans are created via the API.</p>
34
+ </div>
35
+ <% end %>
@@ -0,0 +1,56 @@
1
+ <%= turbo_stream_from @plan %>
2
+
3
+ <%= render partial: "coplan/plans/header", locals: { plan: @plan } %>
4
+
5
+ <div class="plan-content card">
6
+ <% if @plan.current_content.present? %>
7
+ <div class="plan-layout" data-controller="coplan--text-selection" data-coplan--text-selection-plan-id-value="<%= @plan.id %>">
8
+ <div class="plan-layout__content" data-coplan--text-selection-target="content">
9
+ <%= render_markdown(@plan.current_content) %>
10
+
11
+ <div class="comment-popover" data-coplan--text-selection-target="popover" style="display: none;">
12
+ <button type="button" class="btn btn--primary btn--sm" data-action="coplan--text-selection#openCommentForm">
13
+ 💬 Comment
14
+ </button>
15
+ </div>
16
+ </div>
17
+
18
+ <div class="plan-layout__sidebar">
19
+ <%= render partial: "coplan/comment_threads/new_comment_form", locals: { plan: @plan } %>
20
+
21
+ <div class="comment-tabs" data-controller="coplan--tabs" data-coplan--tabs-active-class="comment-tab--active">
22
+ <div class="comment-tabs__nav">
23
+ <button class="comment-tab comment-tab--active" data-coplan--tabs-target="tab" data-action="coplan--tabs#switch coplan--text-selection#repositionThreads" data-index="0">
24
+ Open <span class="comment-tab__count" id="open-thread-count"><%= @active_threads.size if @active_threads.any? %></span>
25
+ </button>
26
+ <button class="comment-tab" data-coplan--tabs-target="tab" data-action="coplan--tabs#switch coplan--text-selection#repositionThreads" data-index="1">
27
+ Resolved <span class="comment-tab__count" id="resolved-thread-count"><%= @archived_threads.size if @archived_threads.any? %></span>
28
+ </button>
29
+ </div>
30
+
31
+ <div class="comment-tabs__panel" data-coplan--tabs-target="panel">
32
+ <div class="comment-threads-list" id="comment-threads">
33
+ <% @active_threads.each do |thread| %>
34
+ <%= render partial: "coplan/comment_threads/thread", locals: { thread: thread, plan: @plan, current_user: current_user } %>
35
+ <% end %>
36
+ <p class="text-sm text-muted" id="open-threads-empty" <% if @active_threads.any? %>style="display: none;"<% end %>>No open comments.</p>
37
+ </div>
38
+ </div>
39
+
40
+ <div class="comment-tabs__panel" data-coplan--tabs-target="panel" style="display: none;">
41
+ <div class="comment-threads-list" id="resolved-comment-threads">
42
+ <% @archived_threads.each do |thread| %>
43
+ <%= render partial: "coplan/comment_threads/thread", locals: { thread: thread, plan: @plan, current_user: current_user } %>
44
+ <% end %>
45
+ <p class="text-sm text-muted" id="resolved-threads-empty" <% if @archived_threads.any? %>style="display: none;"<% end %>>No resolved comments.</p>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ <% else %>
52
+ <div class="empty-state">
53
+ <p>This plan has no content yet.</p>
54
+ </div>
55
+ <% end %>
56
+ </div>
@@ -0,0 +1,8 @@
1
+ <h2>Create New Token</h2>
2
+ <%= form_with url: settings_tokens_path, method: :post, class: "form-inline" do |f| %>
3
+ <div class="form-group">
4
+ <%= f.label :name, "Token Name" %>
5
+ <%= f.text_field :name, name: "api_token[name]", placeholder: "e.g. My Agent", required: true, class: "form-control" %>
6
+ </div>
7
+ <%= f.submit "Create Token", class: "btn btn--primary" %>
8
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <div class="token-reveal">
2
+ <h3>Your new API token</h3>
3
+ <p>Copy this token now. It will not be shown again.</p>
4
+ <div class="token-reveal__value">
5
+ <code><%= raw_token %></code>
6
+ </div>
7
+ </div>
@@ -0,0 +1,24 @@
1
+ <tr id="<%= dom_id(token) %>" class="<%= 'data-table__row--muted' if token.revoked? %>">
2
+ <td>
3
+ <strong><%= token.name %></strong>
4
+ <% if token.token_prefix.present? %>
5
+ <br><code><%= token.token_prefix %>…</code>
6
+ <% end %>
7
+ </td>
8
+ <td>
9
+ <% if token.revoked? %>
10
+ <span class="badge badge--danger">Revoked</span>
11
+ <% elsif token.expired? %>
12
+ <span class="badge badge--warning">Expired</span>
13
+ <% else %>
14
+ <span class="badge badge--success">Active</span>
15
+ <% end %>
16
+ </td>
17
+ <td><%= token.last_used_at ? time_ago_in_words(token.last_used_at) + " ago" : "Never" %></td>
18
+ <td><%= token.created_at.strftime("%b %d, %Y") %></td>
19
+ <td>
20
+ <% unless token.revoked? %>
21
+ <%= button_to "Revoke", settings_token_path(token), method: :delete, class: "btn btn--danger btn--sm" %>
22
+ <% end %>
23
+ </td>
24
+ </tr>
@@ -0,0 +1,11 @@
1
+ <%= turbo_stream.update "token-reveal" do %>
2
+ <%= render "token_reveal", raw_token: @raw_token %>
3
+ <% end %>
4
+
5
+ <%= turbo_stream.update "create-token-form" do %>
6
+ <%= render "form" %>
7
+ <% end %>
8
+
9
+ <%= turbo_stream.prepend "tokens-list" do %>
10
+ <%= render "token_row", token: @api_token %>
11
+ <% end %>
@@ -0,0 +1,5 @@
1
+ <%= turbo_stream.replace @token do %>
2
+ <%= render "token_row", token: @token %>
3
+ <% end %>
4
+
5
+ <%= turbo_stream.update "token-reveal", "" %>
@@ -0,0 +1,32 @@
1
+ <div class="page-header">
2
+ <h1>Settings</h1>
3
+ <p class="page-header__subtitle">Manage API tokens for agent access</p>
4
+ </div>
5
+
6
+ <div id="token-reveal">
7
+ <% if flash[:raw_token].present? %>
8
+ <%= render "token_reveal", raw_token: flash[:raw_token] %>
9
+ <% end %>
10
+ </div>
11
+
12
+ <div class="card" id="create-token-form">
13
+ <%= render "form" %>
14
+ </div>
15
+
16
+ <div class="card" id="tokens-card">
17
+ <h2>Your Tokens</h2>
18
+ <table class="data-table">
19
+ <thead>
20
+ <tr>
21
+ <th>Name</th>
22
+ <th>Status</th>
23
+ <th>Last Used</th>
24
+ <th>Created</th>
25
+ <th></th>
26
+ </tr>
27
+ </thead>
28
+ <tbody id="tokens-list">
29
+ <%= render partial: "token_row", collection: @api_tokens, as: :token %>
30
+ </tbody>
31
+ </table>
32
+ </div>
@@ -0,0 +1,42 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= content_for(:title) || "CoPlan" %></title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+ <%= yield :head %>
9
+ <%= stylesheet_link_tag "coplan/application", "data-turbo-track": "reload" %>
10
+ <%= javascript_importmap_tags %>
11
+ </head>
12
+ <body>
13
+ <nav class="site-nav">
14
+ <div class="site-nav__inner">
15
+ <%= link_to "📋 CoPlan", coplan.root_path, class: "site-nav__brand" %>
16
+ <ul class="site-nav__links">
17
+ <% if signed_in? %>
18
+ <li><%= link_to "Plans", coplan.plans_path %></li>
19
+ <li><%= link_to "Settings", coplan.settings_tokens_path %></li>
20
+ <% end %>
21
+ </ul>
22
+ <% if signed_in? %>
23
+ <div class="site-nav__user">
24
+ <span><%= current_user.name %></span>
25
+ <% if main_app.respond_to?(:sign_out_path) %>
26
+ <%= link_to "Sign out", main_app.sign_out_path, data: { turbo_method: :delete } %>
27
+ <% end %>
28
+ </div>
29
+ <% end %>
30
+ </div>
31
+ </nav>
32
+ <main class="main-content">
33
+ <% if notice.present? %>
34
+ <div class="flash flash--notice"><%= notice %></div>
35
+ <% end %>
36
+ <% if alert.present? %>
37
+ <div class="flash flash--alert"><%= alert %></div>
38
+ <% end %>
39
+ <%= yield %>
40
+ </main>
41
+ </body>
42
+ </html>
@@ -0,0 +1 @@
1
+ pin_all_from CoPlan::Engine.root.join("app/javascript/controllers/coplan"), under: "controllers/coplan", preload: true
data/config/routes.rb ADDED
@@ -0,0 +1,41 @@
1
+ CoPlan::Engine.routes.draw do
2
+ resources :plans, only: [:index, :show, :edit, :update] do
3
+ patch :update_status, on: :member
4
+ resources :versions, controller: "plan_versions", only: [:index, :show]
5
+ resources :automated_reviews, only: [:create]
6
+ resources :comment_threads, only: [:create] do
7
+ member do
8
+ patch :resolve
9
+ patch :accept
10
+ patch :dismiss
11
+ patch :reopen
12
+ end
13
+ resources :comments, only: [:create]
14
+ end
15
+ end
16
+
17
+ namespace :settings do
18
+ resources :tokens, only: [:index, :create, :destroy]
19
+ end
20
+
21
+ namespace :api do
22
+ namespace :v1 do
23
+ resources :plans, only: [:index, :show, :create, :update] do
24
+ get :versions, on: :member
25
+ get :comments, on: :member
26
+ resource :lease, only: [:create, :update, :destroy], controller: "leases"
27
+ resources :operations, only: [:create]
28
+ resources :sessions, only: [:create, :show], controller: "sessions" do
29
+ post :commit, on: :member
30
+ end
31
+ resources :comments, only: [:create], controller: "comments" do
32
+ post :reply, on: :member
33
+ patch :resolve, on: :member
34
+ patch :dismiss, on: :member
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ root "plans#index"
41
+ end