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
@@ -1,20 +1,21 @@
1
1
  module CoPlan
2
2
  module CommentsHelper
3
3
  def comment_author_name(comment)
4
- case comment.author_type
4
+ user_name = case comment.author_type
5
5
  when "human"
6
6
  CoPlan::User.find_by(id: comment.author_id)&.name || "Unknown"
7
7
  when "local_agent"
8
- user_name = CoPlan::User
8
+ CoPlan::User
9
9
  .joins(:api_tokens)
10
10
  .where(coplan_api_tokens: { id: comment.author_id })
11
11
  .pick(:name) || "Agent"
12
- comment.agent_name.present? ? "#{user_name} (#{comment.agent_name})" : user_name
13
12
  when "cloud_persona"
14
13
  AutomatedPlanReviewer.find_by(id: comment.author_id)&.name || "Reviewer"
15
14
  else
16
15
  comment.author_type
17
16
  end
17
+
18
+ comment.agent_name.present? ? "#{comment.agent_name} (via #{user_name})" : user_name
18
19
  end
19
20
  end
20
21
  end
@@ -11,16 +11,22 @@ module CoPlan
11
11
  blockquote hr br
12
12
  dd dt dl
13
13
  sup sub
14
+ details summary
14
15
  ].freeze
15
16
 
16
17
  ALLOWED_ATTRIBUTES = %w[id class href src alt title].freeze
17
18
 
18
19
  def render_markdown(content)
19
- html = Commonmarker.to_html(content.to_s.encode("UTF-8"), plugins: { syntax_highlighter: nil })
20
+ html = Commonmarker.to_html(content.to_s.encode("UTF-8"), options: { render: { unsafe: true } }, plugins: { syntax_highlighter: nil })
20
21
  sanitized = sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
21
22
  tag.div(sanitized, class: "markdown-rendered")
22
23
  end
23
24
 
25
+ def markdown_to_plain_text(content)
26
+ html = Commonmarker.to_html(content.to_s.encode("UTF-8"), plugins: { syntax_highlighter: nil })
27
+ Nokogiri::HTML::DocumentFragment.parse(html).text.squish
28
+ end
29
+
24
30
  def render_line_view(content)
25
31
  lines = content.to_s.split("\n", -1)
26
32
  line_divs = lines.each_with_index.map do |line, index|
@@ -6,13 +6,18 @@ export default class extends Controller {
6
6
 
7
7
  connect() {
8
8
  this.currentIndex = -1
9
+ this.activeMark = null
10
+ this.activePopover = null
9
11
  this.updatePosition()
10
12
  this.handleKeydown = this.handleKeydown.bind(this)
13
+ this.handleScroll = this.handleScroll.bind(this)
11
14
  document.addEventListener("keydown", this.handleKeydown)
15
+ window.addEventListener("scroll", this.handleScroll, { passive: true })
12
16
  }
13
17
 
14
18
  disconnect() {
15
19
  document.removeEventListener("keydown", this.handleKeydown)
20
+ window.removeEventListener("scroll", this.handleScroll)
16
21
  }
17
22
 
18
23
  handleKeydown(event) {
@@ -36,6 +41,14 @@ export default class extends Controller {
36
41
  event.preventDefault()
37
42
  this.focusReply()
38
43
  break
44
+ case "a":
45
+ event.preventDefault()
46
+ this.acceptCurrent()
47
+ break
48
+ case "d":
49
+ event.preventDefault()
50
+ this.discardCurrent()
51
+ break
39
52
  }
40
53
  }
41
54
 
@@ -69,42 +82,142 @@ export default class extends Controller {
69
82
  el.classList.remove("anchor-highlight--active")
70
83
  })
71
84
 
85
+ // Close any currently open popover first — showPopover() throws
86
+ // InvalidStateError if the same popover is already open, and
87
+ // auto-dismiss only works when showing a *different* popover.
88
+ const openPopover = this.findOpenPopover()
89
+ if (openPopover) {
90
+ try { openPopover.hidePopover() } catch {}
91
+ }
92
+
72
93
  // Add active class and scroll into view
73
94
  mark.classList.add("anchor-highlight--active")
74
- mark.scrollIntoView({ behavior: "smooth", block: "center" })
95
+ mark.scrollIntoView({ behavior: "instant", block: "center" })
75
96
 
76
97
  // Open the thread popover if there's one
77
98
  const threadId = mark.dataset.threadId
78
99
  if (threadId) {
79
100
  const popover = document.getElementById(`${threadId}_popover`)
80
101
  if (popover) {
81
- const markRect = mark.getBoundingClientRect()
102
+ popover.style.visibility = "hidden"
82
103
  popover.showPopover()
83
- popover.style.top = `${markRect.top}px`
84
- popover.style.left = `${markRect.right + 12}px`
104
+ this.positionPopoverAtMark(popover, mark)
105
+ popover.style.visibility = "visible"
106
+ this.activeMark = mark
107
+ this.activePopover = popover
85
108
  }
86
109
  }
87
110
 
88
111
  this.updatePosition()
89
112
  }
90
113
 
91
- focusReply() {
92
- let openPopover
114
+ handleScroll() {
115
+ if (!this.activeMark || !this.activePopover) return
93
116
  try {
94
- openPopover = document.querySelector(".thread-popover:popover-open")
95
- } catch {
96
- // :popover-open not supported — find the visible popover manually
97
- openPopover = Array.from(document.querySelectorAll(".thread-popover[popover]"))
98
- .find(el => el.checkVisibility?.())
117
+ if (!this.activePopover.matches(":popover-open")) {
118
+ this.activeMark = null
119
+ this.activePopover = null
120
+ return
121
+ }
122
+ } catch { return }
123
+ this.positionPopoverAtMark(this.activePopover, this.activeMark)
124
+ }
125
+
126
+ positionPopoverAtMark(popover, mark) {
127
+ const markRect = mark.getBoundingClientRect()
128
+ const popoverRect = popover.getBoundingClientRect()
129
+ const viewportWidth = window.innerWidth
130
+ const viewportHeight = window.innerHeight
131
+
132
+ let top = markRect.top
133
+ let left = markRect.right + 12
134
+
135
+ if (left + popoverRect.width > viewportWidth - 16) {
136
+ left = markRect.left - popoverRect.width - 12
137
+ }
138
+ if (top + popoverRect.height > viewportHeight - 16) {
139
+ top = viewportHeight - popoverRect.height - 16
99
140
  }
100
- if (!openPopover) return
141
+ if (top < 16) top = 16
142
+ if (left < 16) left = 16
101
143
 
102
- const textarea = openPopover.querySelector(".thread-popover__reply textarea")
144
+ popover.style.top = `${top}px`
145
+ popover.style.left = `${left}px`
146
+ }
147
+
148
+ focusReply() {
149
+ const popover = this.findOpenPopover()
150
+ if (!popover) return
151
+
152
+ const textarea = popover.querySelector(".thread-popover__reply textarea")
103
153
  if (textarea) {
104
154
  textarea.focus({ preventScroll: true })
105
155
  }
106
156
  }
107
157
 
158
+ acceptCurrent() {
159
+ this.submitPopoverAction("accept")
160
+ }
161
+
162
+ discardCurrent() {
163
+ this.submitPopoverAction("discard")
164
+ }
165
+
166
+ submitPopoverAction(action) {
167
+ const popover = this.findOpenPopover()
168
+ if (!popover) return
169
+
170
+ const form = popover.querySelector(`form[data-action-name='${action}']`)
171
+ if (!form) return
172
+
173
+ // Normalize currentIndex if popover was opened via mouse (not j/k)
174
+ if (this.currentIndex < 0) {
175
+ this.currentIndex = 0
176
+ }
177
+
178
+ // For accept (pending→todo), the thread stays open so we need to
179
+ // explicitly advance. For discard, the thread leaves openHighlights
180
+ // and the current index naturally points to the next one.
181
+ const shouldAdvance = action === "accept"
182
+
183
+ // Watch for the broadcast DOM update that replaces the thread data,
184
+ // then advance to the next thread once the highlights have changed.
185
+ const threadsContainer = document.getElementById("plan-threads")
186
+ if (threadsContainer) {
187
+ const observer = new MutationObserver(() => {
188
+ observer.disconnect()
189
+ this.advanceAfterAction(shouldAdvance)
190
+ })
191
+ observer.observe(threadsContainer, { childList: true, subtree: true })
192
+ }
193
+
194
+ form.requestSubmit()
195
+ }
196
+
197
+ advanceAfterAction(shouldAdvance) {
198
+ const highlights = this.openHighlights
199
+ if (highlights.length === 0) {
200
+ this.currentIndex = -1
201
+ this.updatePosition()
202
+ return
203
+ }
204
+ if (shouldAdvance) {
205
+ this.currentIndex = (this.currentIndex + 1) % highlights.length
206
+ } else if (this.currentIndex >= highlights.length) {
207
+ this.currentIndex = 0
208
+ }
209
+ this.navigateTo(highlights[this.currentIndex])
210
+ }
211
+
212
+ findOpenPopover() {
213
+ try {
214
+ return document.querySelector(".thread-popover:popover-open")
215
+ } catch {
216
+ return Array.from(document.querySelectorAll(".thread-popover[popover]"))
217
+ .find(el => el.checkVisibility?.())
218
+ }
219
+ }
220
+
108
221
  toggleResolved() {
109
222
  const planLayout = document.querySelector(".plan-layout")
110
223
  if (!planLayout) return
@@ -0,0 +1,252 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const STORAGE_KEY = "coplan:content-nav-visible"
4
+
5
+ export default class extends Controller {
6
+ static targets = ["sidebar", "list", "content", "toggleBtn", "showBtn"]
7
+ static values = { visible: { type: Boolean, default: true } }
8
+
9
+ connect() {
10
+ const stored = localStorage.getItem(STORAGE_KEY)
11
+ if (stored !== null) {
12
+ this.visibleValue = stored === "true"
13
+ }
14
+
15
+ this.buildToc()
16
+ this.setupScrollTracking()
17
+
18
+ this.handleKeydown = this.handleKeydown.bind(this)
19
+ document.addEventListener("keydown", this.handleKeydown)
20
+
21
+ this._handleAnchorsUpdated = () => this.updateCommentBadges()
22
+ this.element.addEventListener("coplan:anchors-updated", this._handleAnchorsUpdated)
23
+ }
24
+
25
+ disconnect() {
26
+ if (this._scrollHandler) {
27
+ window.removeEventListener("scroll", this._scrollHandler)
28
+ }
29
+ document.removeEventListener("keydown", this.handleKeydown)
30
+ if (this._handleAnchorsUpdated) {
31
+ this.element.removeEventListener("coplan:anchors-updated", this._handleAnchorsUpdated)
32
+ }
33
+ }
34
+
35
+ buildToc() {
36
+ const rendered = this.contentTarget.querySelector(".markdown-rendered")
37
+ if (!rendered) return
38
+
39
+ this.listTarget.innerHTML = ""
40
+ this._itemsById = new Map()
41
+ this._headings = Array.from(rendered.querySelectorAll("h1, h2, h3"))
42
+
43
+ if (this._headings.length === 0) {
44
+ this.sidebarTarget.style.display = "none"
45
+ if (this.hasShowBtnTarget) this.showBtnTarget.style.display = "none"
46
+ return
47
+ }
48
+ this.sidebarTarget.style.display = ""
49
+ if (this.hasShowBtnTarget) this.showBtnTarget.style.display = ""
50
+
51
+ const usedIds = new Set()
52
+
53
+ this._headings.forEach((heading, index) => {
54
+ let baseId = heading.id || this.slugify(heading.textContent) || `section-${index + 1}`
55
+ let id = baseId
56
+ let suffix = 2
57
+ while (usedIds.has(id)) {
58
+ id = `${baseId}-${suffix++}`
59
+ }
60
+ heading.id = id
61
+ usedIds.add(id)
62
+
63
+ const li = document.createElement("li")
64
+ li.className = `content-nav__item content-nav__item--${heading.tagName.toLowerCase()}`
65
+ li.dataset.headingId = id
66
+
67
+ const a = document.createElement("a")
68
+ a.className = "content-nav__link"
69
+ a.href = `#${id}`
70
+ a.addEventListener("click", (e) => this.handleLinkClick(e, heading))
71
+
72
+ const text = document.createElement("span")
73
+ text.className = "content-nav__link-text"
74
+ text.textContent = heading.textContent
75
+ a.appendChild(text)
76
+
77
+ li.appendChild(a)
78
+ this.listTarget.appendChild(li)
79
+ this._itemsById.set(id, li)
80
+ })
81
+
82
+ this.updateCommentBadges()
83
+ }
84
+
85
+ slugify(text) {
86
+ return text
87
+ .toLowerCase()
88
+ .replace(/\s+/g, "-")
89
+ .replace(/[^a-z0-9-]/g, "")
90
+ .replace(/-{2,}/g, "-")
91
+ .replace(/^-|-$/g, "")
92
+ }
93
+
94
+ setupScrollTracking() {
95
+ if (!this._headings || this._headings.length === 0) return
96
+
97
+ this._scrollHandler = () => {
98
+ if (this._ignoreScroll) {
99
+ clearTimeout(this._scrollEndTimer)
100
+ this._scrollEndTimer = setTimeout(() => {
101
+ this._ignoreScroll = false
102
+ }, 100)
103
+ return
104
+ }
105
+ if (!this._scrollTicking) {
106
+ requestAnimationFrame(() => {
107
+ this._updateActiveFromScroll()
108
+ this._scrollTicking = false
109
+ })
110
+ this._scrollTicking = true
111
+ }
112
+ }
113
+ window.addEventListener("scroll", this._scrollHandler, { passive: true })
114
+ this._updateActiveFromScroll()
115
+ }
116
+
117
+ _updateActiveFromScroll() {
118
+ const threshold = 100
119
+ let active = null
120
+ for (const heading of this._headings) {
121
+ if (heading.getBoundingClientRect().top <= threshold) {
122
+ active = heading
123
+ } else {
124
+ break
125
+ }
126
+ }
127
+
128
+ const id = active?.id || this._headings[0]?.id
129
+ if (id && id !== this._activeHeadingId) {
130
+ this._activeHeadingId = id
131
+ this._setActiveLink(id)
132
+ }
133
+ }
134
+
135
+ handleLinkClick(event, heading) {
136
+ event.preventDefault()
137
+ history.replaceState(null, "", `#${heading.id}`)
138
+
139
+ this._ignoreScroll = true
140
+ this._activeHeadingId = heading.id
141
+ this._setActiveLink(heading.id)
142
+
143
+ heading.scrollIntoView({ behavior: "smooth", block: "start" })
144
+ }
145
+
146
+ _setActiveLink(id) {
147
+ this.listTarget.querySelectorAll(".content-nav__link").forEach(link => {
148
+ link.classList.remove("content-nav__link--active")
149
+ })
150
+
151
+ const item = this._itemsById?.get(id)
152
+ const link = item?.querySelector(".content-nav__link")
153
+ if (link) {
154
+ link.classList.add("content-nav__link--active")
155
+ link.scrollIntoView({ block: "nearest" })
156
+ }
157
+ }
158
+
159
+ toggle() {
160
+ this.visibleValue = !this.visibleValue
161
+ localStorage.setItem(STORAGE_KEY, this.visibleValue)
162
+ }
163
+
164
+ visibleValueChanged() {
165
+ if (!this.hasSidebarTarget) return
166
+
167
+ const hidden = !this.visibleValue
168
+ this.sidebarTarget.classList.toggle("content-nav--hidden", hidden)
169
+ this.sidebarTarget.setAttribute("aria-hidden", String(hidden))
170
+ if (hidden) {
171
+ this.sidebarTarget.setAttribute("inert", "")
172
+ } else {
173
+ this.sidebarTarget.removeAttribute("inert")
174
+ }
175
+
176
+ if (this.hasToggleBtnTarget) {
177
+ this.toggleBtnTarget.setAttribute("aria-expanded", String(this.visibleValue))
178
+ }
179
+ }
180
+
181
+ handleKeydown(event) {
182
+ const tag = event.target.tagName
183
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || event.target.isContentEditable) return
184
+ if (event.metaKey || event.ctrlKey || event.altKey) return
185
+
186
+ if (event.key === "]") {
187
+ event.preventDefault()
188
+ this.toggle()
189
+ }
190
+ }
191
+
192
+ updateCommentBadges() {
193
+ if (!this._headings || this._headings.length === 0) return
194
+
195
+ const rendered = this.contentTarget.querySelector(".markdown-rendered")
196
+ if (!rendered) return
197
+
198
+ this._headings.forEach((heading, index) => {
199
+ const nextHeading = this._headings[index + 1]
200
+ const threads = this.collectThreadsBetween(heading, nextHeading, rendered)
201
+
202
+ let pendingCount = 0
203
+ let todoCount = 0
204
+ threads.forEach(status => {
205
+ if (status === "pending") pendingCount++
206
+ else if (status === "todo") todoCount++
207
+ })
208
+
209
+ const item = this._itemsById?.get(heading.id)
210
+ if (!item) return
211
+
212
+ const existing = item.querySelector(".content-nav__badge")
213
+ if (existing) existing.remove()
214
+
215
+ const total = pendingCount + todoCount
216
+ if (total === 0) return
217
+
218
+ const badge = document.createElement("span")
219
+ const badgeType = pendingCount > 0 ? "pending" : "todo"
220
+ badge.className = `content-nav__badge content-nav__badge--${badgeType}`
221
+ badge.textContent = total
222
+ item.querySelector(".content-nav__link").appendChild(badge)
223
+ })
224
+ }
225
+
226
+ collectThreadsBetween(startHeading, endHeading, container) {
227
+ const seen = new Set()
228
+ const threads = []
229
+ let collecting = false
230
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, null)
231
+
232
+ let node
233
+ while ((node = walker.nextNode())) {
234
+ if (node === startHeading) {
235
+ collecting = true
236
+ continue
237
+ }
238
+ if (endHeading && node === endHeading) break
239
+
240
+ if (collecting && node.tagName === "MARK" && node.classList.contains("anchor-highlight--open")) {
241
+ const threadId = node.dataset.threadId
242
+ if (threadId && !seen.has(threadId)) {
243
+ seen.add(threadId)
244
+ const status = node.classList.contains("anchor-highlight--pending") ? "pending" : "todo"
245
+ threads.push(status)
246
+ }
247
+ }
248
+ }
249
+
250
+ return threads
251
+ }
252
+ }
@@ -0,0 +1,44 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { createConsumer } from "@rails/actioncable"
3
+
4
+ let sharedConsumer
5
+
6
+ function getConsumer() {
7
+ if (!sharedConsumer) sharedConsumer = createConsumer()
8
+ return sharedConsumer
9
+ }
10
+
11
+ export default class extends Controller {
12
+ static values = { planId: String }
13
+
14
+ connect() {
15
+ this.channel = getConsumer().subscriptions.create(
16
+ { channel: "CoPlan::PlanPresenceChannel", plan_id: this.planIdValue },
17
+ {
18
+ connected: () => { this.startPinging() },
19
+ disconnected: () => { this.stopPinging() }
20
+ }
21
+ )
22
+ }
23
+
24
+ disconnect() {
25
+ this.stopPinging()
26
+ if (this.channel) {
27
+ this.channel.unsubscribe()
28
+ this.channel = null
29
+ }
30
+ }
31
+
32
+ startPinging() {
33
+ this.pingInterval = setInterval(() => {
34
+ if (this.channel) this.channel.perform("ping")
35
+ }, 15000)
36
+ }
37
+
38
+ stopPinging() {
39
+ if (this.pingInterval) {
40
+ clearInterval(this.pingInterval)
41
+ this.pingInterval = null
42
+ }
43
+ }
44
+ }