coplan-engine 0.2.0 → 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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/coplan/application.css +350 -7
  3. data/app/channels/coplan/plan_presence_channel.rb +45 -0
  4. data/app/controllers/coplan/api/v1/comments_controller.rb +2 -2
  5. data/app/controllers/coplan/api/v1/operations_controller.rb +41 -3
  6. data/app/controllers/coplan/api/v1/plans_controller.rb +2 -2
  7. data/app/controllers/coplan/api/v1/sessions_controller.rb +2 -2
  8. data/app/controllers/coplan/plans_controller.rb +1 -0
  9. data/app/controllers/coplan/settings/tokens_controller.rb +1 -1
  10. data/app/helpers/coplan/application_helper.rb +57 -0
  11. data/app/helpers/coplan/comments_helper.rb +4 -3
  12. data/app/helpers/coplan/markdown_helper.rb +7 -1
  13. data/app/javascript/controllers/coplan/comment_nav_controller.js +126 -13
  14. data/app/javascript/controllers/coplan/content_nav_controller.js +252 -0
  15. data/app/javascript/controllers/coplan/presence_controller.js +44 -0
  16. data/app/javascript/controllers/coplan/text_selection_controller.js +203 -56
  17. data/app/models/coplan/comment.rb +4 -0
  18. data/app/models/coplan/comment_thread.rb +58 -16
  19. data/app/models/coplan/plan.rb +1 -0
  20. data/app/models/coplan/plan_viewer.rb +26 -0
  21. data/app/services/coplan/plans/apply_operations.rb +43 -0
  22. data/app/services/coplan/plans/commit_session.rb +26 -1
  23. data/app/services/coplan/plans/markdown_text_extractor.rb +142 -0
  24. data/app/services/coplan/plans/position_resolver.rb +111 -0
  25. data/app/views/coplan/comment_threads/_reply_form.html.erb +1 -1
  26. data/app/views/coplan/comment_threads/_thread_popover.html.erb +4 -4
  27. data/app/views/coplan/comments/_comment.html.erb +3 -0
  28. data/app/views/coplan/plans/_header.html.erb +1 -0
  29. data/app/views/coplan/plans/_viewers.html.erb +16 -0
  30. data/app/views/coplan/plans/show.html.erb +25 -3
  31. data/app/views/layouts/coplan/application.html.erb +2 -0
  32. data/db/migrate/20260327000000_create_coplan_plan_viewers.rb +15 -0
  33. data/lib/coplan/version.rb +1 -1
  34. metadata +8 -1
@@ -0,0 +1,142 @@
1
+ module CoPlan
2
+ module Plans
3
+ # Extracts plain text from markdown using the Commonmarker AST, returning
4
+ # [stripped_string, position_map] where position_map[i] is the character
5
+ # index in the raw content of stripped_string[i].
6
+ #
7
+ # This handles all markdown constructs: inline formatting (`**`, `*`, `` ` ``,
8
+ # `~~`), tables, links, images, lists, blockquotes, headings, etc.
9
+ #
10
+ # Usage:
11
+ # stripped, pos_map = MarkdownTextExtractor.call("Hello **world**")
12
+ # # stripped => "Hello world"
13
+ # # pos_map => [0, 1, 2, 3, 4, 5, 8, 9, 10, 11, 12]
14
+ class MarkdownTextExtractor
15
+ def self.call(content)
16
+ new(content).call
17
+ end
18
+
19
+ def initialize(content)
20
+ @content = content
21
+ end
22
+
23
+ def call
24
+ doc = Commonmarker.parse(@content)
25
+ byte_to_char = build_byte_to_char_map
26
+ line_byte_offsets = build_line_byte_offsets
27
+ stripped = +""
28
+ pos_map = []
29
+
30
+ extract_text_nodes(doc, line_byte_offsets, byte_to_char, stripped, pos_map)
31
+
32
+ [stripped, pos_map]
33
+ end
34
+
35
+ private
36
+
37
+ # Builds a map from byte offset to character index. Commonmarker reports
38
+ # source positions using byte-based columns, but Ruby string indexing
39
+ # uses character positions.
40
+ def build_byte_to_char_map
41
+ map = {}
42
+ byte_offset = 0
43
+ @content.each_char.with_index do |char, char_idx|
44
+ map[byte_offset] = char_idx
45
+ byte_offset += char.bytesize
46
+ end
47
+ map
48
+ end
49
+
50
+ # Builds an array mapping 1-based line numbers to byte offsets.
51
+ # line_byte_offsets[line_number] = byte offset of the first byte on that line.
52
+ def build_line_byte_offsets
53
+ offsets = [nil, 0] # index 0 unused; line 1 starts at byte 0
54
+ byte_offset = 0
55
+ @content.each_char do |char|
56
+ byte_offset += char.bytesize
57
+ offsets << byte_offset if char == "\n"
58
+ end
59
+ offsets
60
+ end
61
+
62
+ # Block-level node types that should be separated by newlines.
63
+ BLOCK_TYPES = %i[paragraph heading table table_row item block_quote list code_block].to_set.freeze
64
+
65
+ # Recursively walks the AST, appending text content to `stripped` and
66
+ # character-index mappings to `pos_map`. Inserts whitespace between
67
+ # block elements and table cells to match browser DOM text behavior.
68
+ def extract_text_nodes(node, line_byte_offsets, byte_to_char, stripped, pos_map)
69
+ prev_was_block = false
70
+
71
+ node.each do |child|
72
+ # Insert a space between adjacent table cells, and a newline
73
+ # between block-level siblings (paragraphs, rows, items, etc.).
74
+ if child.type == :table_cell
75
+ append_separator(stripped, pos_map, " ") if prev_was_block
76
+ prev_was_block = true
77
+ elsif BLOCK_TYPES.include?(child.type)
78
+ append_separator(stripped, pos_map, "\n") if prev_was_block
79
+ prev_was_block = true
80
+ end
81
+
82
+ case child.type
83
+ when :text
84
+ pos = child.source_position
85
+ start_byte = line_byte_offsets[pos[:start_line]] + pos[:start_column] - 1
86
+ char_idx = byte_to_char[start_byte]
87
+ child.string_content.each_char.with_index do |char, i|
88
+ stripped << char
89
+ pos_map << (char_idx + i)
90
+ end
91
+ when :code
92
+ # source_position includes backtick delimiters; find the inner
93
+ # content start by scanning past them in the raw string.
94
+ pos = child.source_position
95
+ start_byte = line_byte_offsets[pos[:start_line]] + pos[:start_column] - 1
96
+ node_char_start = byte_to_char[start_byte]
97
+ end_byte = line_byte_offsets[pos[:end_line]] + pos[:end_column] - 1
98
+ node_char_end = byte_to_char[end_byte]
99
+ text = child.string_content
100
+ node_char_len = node_char_end - node_char_start + 1
101
+ tick_len = (node_char_len - text.length) / 2
102
+ content_char_start = node_char_start + tick_len
103
+ text.each_char.with_index do |char, i|
104
+ stripped << char
105
+ pos_map << (content_char_start + i)
106
+ end
107
+ when :code_block
108
+ # Fenced code blocks: source_position spans from the opening
109
+ # fence to the closing fence. The string_content is the inner
110
+ # text (excluding fences). Content starts on the line after
111
+ # the opening fence.
112
+ pos = child.source_position
113
+ text = child.string_content
114
+ content_line = pos[:start_line] + 1
115
+ if content_line <= line_byte_offsets.length - 1
116
+ content_byte = line_byte_offsets[content_line]
117
+ char_idx = byte_to_char[content_byte]
118
+ text.each_char.with_index do |char, i|
119
+ stripped << char
120
+ pos_map << (char_idx + i)
121
+ end
122
+ end
123
+ when :softbreak, :linebreak
124
+ pos = child.source_position
125
+ start_byte = line_byte_offsets[pos[:start_line]] + pos[:start_column] - 1
126
+ stripped << "\n"
127
+ pos_map << byte_to_char[start_byte]
128
+ else
129
+ extract_text_nodes(child, line_byte_offsets, byte_to_char, stripped, pos_map)
130
+ end
131
+ end
132
+ end
133
+
134
+ # Appends a synthetic separator character to the stripped text.
135
+ # Maps it to -1 since it doesn't correspond to any raw source position.
136
+ def append_separator(stripped, pos_map, char)
137
+ stripped << char
138
+ pos_map << -1
139
+ end
140
+ end
141
+ end
142
+ end
@@ -20,6 +20,8 @@ module CoPlan
20
20
  resolve_insert_under_heading
21
21
  when "delete_paragraph_containing"
22
22
  resolve_delete_paragraph_containing
23
+ when "replace_section"
24
+ resolve_replace_section
23
25
  else
24
26
  raise OperationError, "Unknown operation: #{@op["op"]}"
25
27
  end
@@ -153,6 +155,115 @@ module CoPlan
153
155
  paragraphs
154
156
  end
155
157
 
158
+ def resolve_replace_section
159
+ heading = @op["heading"]
160
+ raise OperationError, "replace_section requires 'heading'" if heading.blank?
161
+ raise OperationError, "replace_section requires 'new_content'" if @op["new_content"].nil?
162
+
163
+ include_heading = @op.fetch("include_heading", true)
164
+ # Normalize: accept both string and boolean
165
+ include_heading = include_heading != false && include_heading != "false"
166
+
167
+ headings = parse_headings(@content)
168
+ matches = headings.select { |h| h[:text] == heading }
169
+
170
+ if matches.empty?
171
+ raise OperationError, "replace_section: heading_not_found — no heading matching '#{heading}'"
172
+ end
173
+
174
+ if matches.length > 1
175
+ match_details = matches.map { |m| { heading: m[:text], line: m[:line] } }
176
+ raise OperationError, "replace_section: ambiguous_heading — found #{matches.length} headings matching '#{heading}': #{match_details.inspect}"
177
+ end
178
+
179
+ match = matches.first
180
+ target_level = match[:level]
181
+
182
+ # Section starts at the heading line start
183
+ section_start = match[:line_start]
184
+
185
+ # Section ends at the next heading of equal or higher level, or EOF
186
+ next_heading = headings.find { |h| h[:line_start] > match[:line_start] && h[:level] <= target_level }
187
+ section_end = next_heading ? next_heading[:line_start] : @content.length
188
+
189
+ # Strip all trailing newlines from the section range so the separator
190
+ # between sections falls outside the replaced range. This ensures
191
+ # replacement content won't merge into the next heading.
192
+ section_end = section_end.to_i
193
+ while section_end > section_start && @content[section_end - 1] == "\n"
194
+ section_end -= 1
195
+ end
196
+
197
+ range = if include_heading
198
+ [section_start, section_end]
199
+ else
200
+ # Skip past the heading line itself
201
+ heading_line_end = @content.index("\n", section_start)
202
+ if heading_line_end
203
+ body_start = heading_line_end + 1
204
+ # Skip blank line after heading
205
+ while body_start < section_end && @content[body_start] == "\n"
206
+ body_start += 1
207
+ end
208
+ # When trailing newlines are stripped, section_end can retreat
209
+ # behind body_start. Use an empty range at body_start to avoid
210
+ # an inverted range and keep the insertion point after the heading newline.
211
+ [body_start, [body_start, section_end].max]
212
+ else
213
+ # Heading is the only line — body is empty
214
+ [section_end, section_end]
215
+ end
216
+ end
217
+
218
+ Resolution.new(op: "replace_section", ranges: [range])
219
+ end
220
+
221
+ # Parse markdown headings, respecting code fences (``` blocks).
222
+ # Returns an array of hashes: { text:, level:, line:, line_start:, line_end: }
223
+ def parse_headings(content)
224
+ headings = []
225
+ in_code_fence = false
226
+ fence_char = nil
227
+ fence_length = 0
228
+ line_number = 0
229
+ pos = 0
230
+
231
+ content.each_line do |line|
232
+ line_number += 1
233
+ line_start = pos
234
+ line_end = pos + line.length
235
+ stripped = line.chomp
236
+
237
+ fence_match = stripped.match(/\A(`{3,}|~{3,})(.*)\z/)
238
+ if fence_match
239
+ fence_chars = fence_match[1]
240
+ info_string = fence_match[2]
241
+ if in_code_fence
242
+ # Close only if fence char and length match, and no info string
243
+ if fence_chars[0] == fence_char && fence_chars.length >= fence_length && info_string.strip.empty?
244
+ in_code_fence = false
245
+ end
246
+ else
247
+ in_code_fence = true
248
+ fence_char = fence_chars[0]
249
+ fence_length = fence_chars.length
250
+ end
251
+ elsif !in_code_fence && (m = stripped.match(/\A(\#{1,6})\s+(.+)/))
252
+ headings << {
253
+ level: m[1].length,
254
+ text: stripped,
255
+ line: line_number,
256
+ line_start: line_start,
257
+ line_end: line_end
258
+ }
259
+ end
260
+
261
+ pos = line_end
262
+ end
263
+
264
+ headings
265
+ end
266
+
156
267
  # Determine the character range to delete so that removing
157
268
  # content[range[0]...range[1]] produces clean output with
158
269
  # correct paragraph spacing.
@@ -1,7 +1,7 @@
1
1
  <div class="comment-thread__reply" id="<%= dom_id(thread, :reply_form) %>">
2
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
3
  <div class="form-group">
4
- <textarea name="comment[body_markdown]" rows="2" placeholder="Reply..." required
4
+ <textarea name="comment[body_markdown]" rows="2" placeholder="Press r to reply" required
5
5
  data-controller="coplan--comment-form" data-action="keydown->coplan--comment-form#submitOnEnter"></textarea>
6
6
  </div>
7
7
  <button type="submit" class="btn btn--secondary btn--sm">Reply</button>
@@ -7,7 +7,7 @@
7
7
 
8
8
  <div popover="auto" id="<%= dom_id(thread) %>_popover" class="thread-popover">
9
9
  <div class="thread-popover__header">
10
- <span class="badge badge--<%= thread.open? ? 'live' : 'abandoned' %>"><%= thread.status %></span>
10
+ <span class="badge badge--<%= thread.status %>"><%= thread.status %></span>
11
11
  <% if thread.out_of_date? %>
12
12
  <span class="badge badge--abandoned">out of date</span>
13
13
  <% end %>
@@ -32,7 +32,7 @@
32
32
  <div class="thread-popover__reply">
33
33
  <%= form_with url: plan_comment_thread_comments_path(plan, thread), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetReplyForm" } do |f| %>
34
34
  <div class="form-group">
35
- <textarea name="comment[body_markdown]" rows="2" placeholder="Reply..." required
35
+ <textarea name="comment[body_markdown]" rows="2" placeholder="Press r to reply" required
36
36
  data-controller="coplan--comment-form" data-action="keydown->coplan--comment-form#submitOnEnter"></textarea>
37
37
  </div>
38
38
  <button type="submit" class="btn btn--secondary btn--sm">Reply</button>
@@ -42,9 +42,9 @@
42
42
  <div class="thread-popover__actions">
43
43
  <% if is_plan_author %>
44
44
  <% if thread.status == "pending" %>
45
- <%= button_to "Accept", accept_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm" %>
45
+ <%= button_to "Accept (a)", accept_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm", form: { "data-action-name": "accept" } %>
46
46
  <% end %>
47
- <%= button_to "Discard", discard_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm" %>
47
+ <%= button_to "Discard (d)", discard_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm", form: { "data-action-name": "discard" } %>
48
48
  <% end %>
49
49
  </div>
50
50
  <% else %>
@@ -1,6 +1,9 @@
1
1
  <div class="comment" id="<%= dom_id(comment) %>">
2
2
  <div class="comment__header text-sm text-muted">
3
3
  <strong><%= comment_author_name(comment) %></strong>
4
+ <% if comment.agent? %>
5
+ <span class="badge badge--agent">agent</span>
6
+ <% end %>
4
7
  · <%= time_ago_in_words(comment.created_at) %> ago
5
8
  </div>
6
9
  <div class="comment__body">
@@ -6,6 +6,7 @@
6
6
  </div>
7
7
  </div>
8
8
  <div class="page-header__actions">
9
+ <%= render partial: "coplan/plans/viewers", locals: { viewers: CoPlan::PlanViewer.active_viewers_for(plan), current_user: current_user } %>
9
10
  <% if CoPlan::AutomatedPlanReviewer.enabled.any? %>
10
11
  <div class="dropdown" data-controller="coplan--dropdown">
11
12
  <button class="btn btn--secondary" data-action="coplan--dropdown#toggle">
@@ -0,0 +1,16 @@
1
+ <div id="plan-viewers" class="plan-viewers">
2
+ <% if viewers.any? %>
3
+ <div class="plan-viewers__avatars">
4
+ <% viewers.first(8).each do |viewer| %>
5
+ <% is_you = defined?(current_user) && current_user&.id == viewer.id %>
6
+ <span class="plan-viewers__avatar<%= ' plan-viewers__avatar--you' if is_you %>" data-tooltip="<%= is_you ? "#{viewer.name} (you)" : viewer.name %>">
7
+ <%= viewer.name.split.map { |w| w[0] }.first(2).join.upcase %>
8
+ </span>
9
+ <% end %>
10
+ <% if viewers.size > 8 %>
11
+ <span class="plan-viewers__overflow">+<%= viewers.size - 8 %></span>
12
+ <% end %>
13
+ </div>
14
+ <span class="plan-viewers__label"><%= viewers.size %> viewing</span>
15
+ <% end %>
16
+ </div>
@@ -1,12 +1,34 @@
1
+ <% content_for(:title, "#{@plan.title} — CoPlan") %>
2
+ <% content_for(:head) do %>
3
+ <meta property="og:title" content="<%= @plan.title %>">
4
+ <meta property="og:description" content="<%= plan_og_description(@plan) %>">
5
+ <meta property="og:type" content="article">
6
+ <meta property="og:site_name" content="CoPlan">
7
+ <meta name="twitter:card" content="summary">
8
+ <meta name="twitter:title" content="<%= @plan.title %>">
9
+ <meta name="twitter:description" content="<%= plan_og_description(@plan) %>">
10
+ <% end %>
11
+
1
12
  <%= turbo_stream_from @plan %>
2
13
 
3
- <%= render partial: "coplan/plans/header", locals: { plan: @plan } %>
14
+ <div data-controller="coplan--presence" data-coplan--presence-plan-id-value="<%= @plan.id %>">
15
+ <%= render partial: "coplan/plans/header", locals: { plan: @plan } %>
16
+ </div>
4
17
 
5
18
  <div class="plan-content card">
6
19
  <% if @plan.current_content.present? %>
7
- <div class="plan-layout" data-controller="coplan--text-selection" data-coplan--text-selection-plan-id-value="<%= @plan.id %>">
20
+ <div class="plan-layout" data-controller="coplan--text-selection coplan--content-nav" data-coplan--text-selection-plan-id-value="<%= @plan.id %>" data-action="keydown.esc@document->coplan--text-selection#dismiss">
21
+ <nav class="content-nav" data-coplan--content-nav-target="sidebar" aria-label="Document outline">
22
+ <div class="content-nav__header">
23
+ <span class="content-nav__title">Contents</span>
24
+ <button type="button" class="content-nav__toggle" data-action="coplan--content-nav#toggle" data-coplan--content-nav-target="toggleBtn" aria-label="Hide table of contents" title="Toggle (])">✕</button>
25
+ </div>
26
+ <ul class="content-nav__list" data-coplan--content-nav-target="list"></ul>
27
+ </nav>
28
+ <button type="button" class="content-nav-show-btn" data-action="coplan--content-nav#toggle" data-coplan--content-nav-target="showBtn" title="Show table of contents (])">☰ <kbd>]</kbd></button>
29
+
8
30
  <div class="plan-layout__margin" data-coplan--text-selection-target="margin"></div>
9
- <div class="plan-layout__content" data-coplan--text-selection-target="content">
31
+ <div class="plan-layout__content" data-coplan--text-selection-target="content" data-coplan--content-nav-target="content">
10
32
  <%= render_markdown(@plan.current_content) %>
11
33
 
12
34
  <div class="comment-popover" data-coplan--text-selection-target="popover" style="display: none;">
@@ -5,6 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
6
  <%= csrf_meta_tags %>
7
7
  <%= csp_meta_tag %>
8
+ <%= coplan_favicon_tag %>
8
9
  <%= yield :head %>
9
10
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -18,6 +19,7 @@
18
19
  <%= link_to coplan.root_path, class: "site-nav__brand" do %>
19
20
  <%= image_tag "coplan/coplan-logo-sm.png", alt: "CoPlan", class: "site-nav__logo" %>
20
21
  CoPlan
22
+ <%= coplan_environment_badge %>
21
23
  <% end %>
22
24
  <ul class="site-nav__links">
23
25
  <% if signed_in? %>
@@ -0,0 +1,15 @@
1
+ class CreateCoplanPlanViewers < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :coplan_plan_viewers, id: { type: :string, limit: 36 } do |t|
4
+ t.string :plan_id, limit: 36, null: false
5
+ t.string :user_id, limit: 36, null: false
6
+ t.datetime :last_seen_at, null: false
7
+ t.timestamps
8
+ end
9
+
10
+ add_index :coplan_plan_viewers, [:plan_id, :user_id], unique: true
11
+ add_index :coplan_plan_viewers, :last_seen_at
12
+ add_foreign_key :coplan_plan_viewers, :coplan_plans, column: :plan_id
13
+ add_foreign_key :coplan_plan_viewers, :coplan_users, column: :user_id
14
+ end
15
+ end
@@ -1,3 +1,3 @@
1
1
  module CoPlan
2
- VERSION = "0.2.0"
2
+ VERSION = "1.0.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coplan-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Block
@@ -142,6 +142,7 @@ extra_rdoc_files: []
142
142
  files:
143
143
  - app/assets/images/coplan/coplan-logo-sm.png
144
144
  - app/assets/stylesheets/coplan/application.css
145
+ - app/channels/coplan/plan_presence_channel.rb
145
146
  - app/controllers/coplan/api/v1/base_controller.rb
146
147
  - app/controllers/coplan/api/v1/comments_controller.rb
147
148
  - app/controllers/coplan/api/v1/leases_controller.rb
@@ -161,8 +162,10 @@ files:
161
162
  - app/helpers/coplan/markdown_helper.rb
162
163
  - app/javascript/controllers/coplan/comment_form_controller.js
163
164
  - app/javascript/controllers/coplan/comment_nav_controller.js
165
+ - app/javascript/controllers/coplan/content_nav_controller.js
164
166
  - app/javascript/controllers/coplan/dropdown_controller.js
165
167
  - app/javascript/controllers/coplan/line_selection_controller.js
168
+ - app/javascript/controllers/coplan/presence_controller.js
166
169
  - app/javascript/controllers/coplan/text_selection_controller.js
167
170
  - app/jobs/coplan/application_job.rb
168
171
  - app/jobs/coplan/automated_review_job.rb
@@ -179,6 +182,7 @@ files:
179
182
  - app/models/coplan/plan.rb
180
183
  - app/models/coplan/plan_collaborator.rb
181
184
  - app/models/coplan/plan_version.rb
185
+ - app/models/coplan/plan_viewer.rb
182
186
  - app/models/coplan/user.rb
183
187
  - app/policies/coplan/application_policy.rb
184
188
  - app/policies/coplan/comment_thread_policy.rb
@@ -189,6 +193,7 @@ files:
189
193
  - app/services/coplan/plans/apply_operations.rb
190
194
  - app/services/coplan/plans/commit_session.rb
191
195
  - app/services/coplan/plans/create.rb
196
+ - app/services/coplan/plans/markdown_text_extractor.rb
192
197
  - app/services/coplan/plans/operation_error.rb
193
198
  - app/services/coplan/plans/position_resolver.rb
194
199
  - app/services/coplan/plans/review_prompt_formatter.rb
@@ -204,6 +209,7 @@ files:
204
209
  - app/views/coplan/plan_versions/index.html.erb
205
210
  - app/views/coplan/plan_versions/show.html.erb
206
211
  - app/views/coplan/plans/_header.html.erb
212
+ - app/views/coplan/plans/_viewers.html.erb
207
213
  - app/views/coplan/plans/edit.html.erb
208
214
  - app/views/coplan/plans/index.html.erb
209
215
  - app/views/coplan/plans/show.html.erb
@@ -219,6 +225,7 @@ files:
219
225
  - db/migrate/20260226200000_create_coplan_schema.rb
220
226
  - db/migrate/20260313210000_expand_content_markdown_to_mediumtext.rb
221
227
  - db/migrate/20260320145453_migrate_comment_thread_statuses.rb
228
+ - db/migrate/20260327000000_create_coplan_plan_viewers.rb
222
229
  - lib/coplan.rb
223
230
  - lib/coplan/configuration.rb
224
231
  - lib/coplan/engine.rb