coplan-engine 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc0c571fae079ecc474da55dea08af61a198ca761f6f3c9b5c3b672afe3b838c
4
- data.tar.gz: 791565182e62dab38561ae29eeb9794cf0f0087222449b4ef5842525b5e28424
3
+ metadata.gz: 25dc4e49ba0c60553ed7d736f692711ed95908f904ced5ab0eab166a6afd5454
4
+ data.tar.gz: 9081eee65fbcadaef5bfb8af91a50bf67a1825382ff8763de0d3b1cb38a00ac4
5
5
  SHA512:
6
- metadata.gz: 973c44900f288a2ba876c9ee2df96e47e13668f271c3b5e1de187ddf4d935b4fef63de2dac2753cf1acc82628a4a4e3987c8e8e3ea8cd3e38a9ecd123e7b734e
7
- data.tar.gz: e7866646881f7c217cb5b23e0adac89651e6fa47facfe9f4f33049a4e2a7362f8eb209f40dc161dbd43bdd689e643248c050be0044821efeae6abcb97c13aa08
6
+ metadata.gz: 3d75ee11d917e817192149b2a69a33b1089333312ebd8ac4f7b64142479d5108c5b70e801c6b4ae3b64f78e9190d123164f4e5b87f56ced1aad7df0d9b72cb93
7
+ data.tar.gz: c6b96bd3fe16455118551bd01ae3769914bcc2f41d0b2bd59bf159ee8eddd6e84a7fb24ebb74461d522d07802b242b08bc8f691acf9772dabcc0864ab4fdfaed
@@ -21,6 +21,8 @@
21
21
  --color-status-developing: #3b82f6;
22
22
  --color-status-live: #10b981;
23
23
  --color-status-abandoned: #6b7280;
24
+ --color-agent: #4338ca;
25
+ --color-agent-bg: #e0e7ff;
24
26
 
25
27
  /* Spacing */
26
28
  --space-xs: 0.25rem;
@@ -184,6 +186,87 @@ img, svg {
184
186
  font-weight: 700;
185
187
  }
186
188
 
189
+ /* Plan viewers / presence */
190
+ .plan-viewers {
191
+ display: flex;
192
+ align-items: center;
193
+ gap: var(--space-sm);
194
+ }
195
+
196
+ .plan-viewers__avatars {
197
+ display: flex;
198
+ flex-direction: row-reverse;
199
+ }
200
+
201
+ .plan-viewers__avatar {
202
+ display: flex;
203
+ align-items: center;
204
+ justify-content: center;
205
+ width: 2rem;
206
+ height: 2rem;
207
+ border-radius: 50%;
208
+ background: var(--color-primary);
209
+ color: #fff;
210
+ font-size: 0.7rem;
211
+ font-weight: 600;
212
+ border: 2px solid var(--color-surface);
213
+ margin-left: -0.5rem;
214
+ cursor: default;
215
+ position: relative;
216
+ }
217
+
218
+ .plan-viewers__avatar--you {
219
+ background: var(--color-success);
220
+ }
221
+
222
+ .plan-viewers__avatar::after {
223
+ content: attr(data-tooltip);
224
+ position: absolute;
225
+ bottom: calc(100% + 6px);
226
+ left: 50%;
227
+ transform: translateX(-50%);
228
+ background: var(--color-text);
229
+ color: var(--color-surface);
230
+ padding: 4px 8px;
231
+ border-radius: var(--radius);
232
+ font-size: 0.75rem;
233
+ font-weight: 500;
234
+ white-space: nowrap;
235
+ pointer-events: none;
236
+ opacity: 0;
237
+ transition: opacity 0.15s;
238
+ z-index: 10;
239
+ }
240
+
241
+ .plan-viewers__avatar:hover::after {
242
+ opacity: 1;
243
+ }
244
+
245
+ .plan-viewers__avatars > :last-child {
246
+ margin-left: 0;
247
+ }
248
+
249
+ .plan-viewers__overflow {
250
+ display: flex;
251
+ align-items: center;
252
+ justify-content: center;
253
+ width: 2rem;
254
+ height: 2rem;
255
+ border-radius: 50%;
256
+ background: var(--color-border);
257
+ color: var(--color-text-muted);
258
+ font-size: 0.7rem;
259
+ font-weight: 600;
260
+ border: 2px solid var(--color-surface);
261
+ margin-left: -0.5rem;
262
+ }
263
+
264
+ .plan-viewers__label {
265
+ font-size: var(--text-sm);
266
+ color: var(--color-text-muted);
267
+ white-space: nowrap;
268
+ }
269
+
187
270
  /* Flash messages */
188
271
  .flash {
189
272
  padding: var(--space-md) var(--space-lg);
@@ -283,6 +366,7 @@ img, svg {
283
366
  .badge--todo { background: #dbeafe; color: var(--color-status-developing); }
284
367
  .badge--discarded { background: #f3f4f6; color: var(--color-status-abandoned); }
285
368
  .badge--resolved { background: #d1fae5; color: var(--color-status-live); }
369
+ .badge--agent { background: var(--color-agent-bg); color: var(--color-agent); font-size: 0.65rem; padding: 1px 6px; vertical-align: middle; }
286
370
 
287
371
  /* Forms */
288
372
  .form-group {
@@ -722,6 +806,18 @@ img, svg {
722
806
  min-width: 0;
723
807
  }
724
808
 
809
+ #plan-threads {
810
+ position: absolute;
811
+ width: 0;
812
+ height: 0;
813
+ overflow: hidden;
814
+ pointer-events: none;
815
+ }
816
+
817
+ #plan-threads .thread-popover {
818
+ pointer-events: auto;
819
+ }
820
+
725
821
  /* Comment form */
726
822
  .comment-form {
727
823
  position: absolute;
@@ -1143,3 +1239,194 @@ body:has(.comment-toolbar) .main-content {
1143
1239
  .comment-toolbar__toggle input[type="checkbox"] {
1144
1240
  cursor: pointer;
1145
1241
  }
1242
+
1243
+ /* Content navigation sidebar */
1244
+ .content-nav {
1245
+ position: sticky;
1246
+ top: calc(var(--nav-height) + var(--space-lg));
1247
+ align-self: flex-start;
1248
+ width: 220px;
1249
+ flex-shrink: 0;
1250
+ max-height: calc(100vh - var(--nav-height) - var(--space-xl) * 2);
1251
+ transition: width 0.2s ease, opacity 0.2s ease;
1252
+ margin-right: var(--space-lg);
1253
+ }
1254
+
1255
+ .content-nav--hidden {
1256
+ width: 0;
1257
+ opacity: 0;
1258
+ overflow: hidden;
1259
+ margin-right: 0;
1260
+ }
1261
+
1262
+ .content-nav__header {
1263
+ display: flex;
1264
+ align-items: center;
1265
+ justify-content: space-between;
1266
+ padding-bottom: var(--space-sm);
1267
+ margin-bottom: var(--space-sm);
1268
+ border-bottom: 1px solid var(--color-border);
1269
+ }
1270
+
1271
+ .content-nav__title {
1272
+ font-size: var(--text-sm);
1273
+ font-weight: 600;
1274
+ color: var(--color-text-muted);
1275
+ text-transform: uppercase;
1276
+ letter-spacing: 0.05em;
1277
+ }
1278
+
1279
+ .content-nav__toggle {
1280
+ background: none;
1281
+ border: none;
1282
+ cursor: pointer;
1283
+ color: var(--color-text-muted);
1284
+ padding: 2px;
1285
+ font-size: var(--text-sm);
1286
+ line-height: 1;
1287
+ border-radius: var(--radius);
1288
+ transition: color 0.15s;
1289
+ }
1290
+
1291
+ .content-nav__toggle:hover {
1292
+ color: var(--color-text);
1293
+ }
1294
+
1295
+ .content-nav__list {
1296
+ list-style: none;
1297
+ padding: 0;
1298
+ margin: 0;
1299
+ overflow-y: auto;
1300
+ max-height: calc(100vh - var(--nav-height) - 8rem);
1301
+ scrollbar-width: thin;
1302
+ scrollbar-color: var(--color-border) transparent;
1303
+ }
1304
+
1305
+ .content-nav__item {
1306
+ margin: 0;
1307
+ }
1308
+
1309
+ .content-nav__item--h1 {
1310
+ /* top level — no indent */
1311
+ }
1312
+
1313
+ .content-nav__item--h2 {
1314
+ padding-left: var(--space-md);
1315
+ }
1316
+
1317
+ .content-nav__item--h3 {
1318
+ padding-left: var(--space-xl);
1319
+ }
1320
+
1321
+ .content-nav__link {
1322
+ display: flex;
1323
+ align-items: center;
1324
+ gap: var(--space-xs);
1325
+ padding: 3px var(--space-sm);
1326
+ font-size: 0.8rem;
1327
+ line-height: 1.4;
1328
+ color: var(--color-text-muted);
1329
+ text-decoration: none;
1330
+ border-radius: var(--radius);
1331
+ border-left: 2px solid transparent;
1332
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
1333
+ word-break: break-word;
1334
+ }
1335
+
1336
+ .content-nav__link:hover {
1337
+ color: var(--color-text);
1338
+ background: var(--color-bg);
1339
+ text-decoration: none;
1340
+ }
1341
+
1342
+ .content-nav__link--active {
1343
+ color: var(--color-primary);
1344
+ border-left-color: var(--color-primary);
1345
+ font-weight: 600;
1346
+ }
1347
+
1348
+ .content-nav__link-text {
1349
+ flex: 1;
1350
+ min-width: 0;
1351
+ }
1352
+
1353
+ .content-nav__badge {
1354
+ flex-shrink: 0;
1355
+ font-size: 0.65rem;
1356
+ font-weight: 700;
1357
+ min-width: 16px;
1358
+ height: 16px;
1359
+ display: inline-flex;
1360
+ align-items: center;
1361
+ justify-content: center;
1362
+ border-radius: 8px;
1363
+ color: white;
1364
+ line-height: 1;
1365
+ }
1366
+
1367
+ .content-nav__badge--pending {
1368
+ background: var(--color-status-considering);
1369
+ }
1370
+
1371
+ .content-nav__badge--todo {
1372
+ background: var(--color-status-developing);
1373
+ }
1374
+
1375
+ /* Toggle button when sidebar is hidden — shown outside the sidebar */
1376
+ .content-nav-show-btn {
1377
+ position: sticky;
1378
+ top: calc(var(--nav-height) + var(--space-lg));
1379
+ align-self: flex-start;
1380
+ flex-shrink: 0;
1381
+ background: var(--color-surface);
1382
+ border: 1px solid var(--color-border);
1383
+ border-radius: var(--radius);
1384
+ padding: var(--space-xs) var(--space-sm);
1385
+ cursor: pointer;
1386
+ color: var(--color-text-muted);
1387
+ font-size: var(--text-sm);
1388
+ box-shadow: var(--shadow);
1389
+ transition: color 0.15s, box-shadow 0.15s;
1390
+ margin-right: var(--space-sm);
1391
+ display: none;
1392
+ white-space: nowrap;
1393
+ }
1394
+
1395
+ .content-nav-show-btn:hover {
1396
+ color: var(--color-text);
1397
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
1398
+ }
1399
+
1400
+ .content-nav-show-btn kbd {
1401
+ font-family: var(--font-mono);
1402
+ font-size: 0.7rem;
1403
+ background: var(--color-bg);
1404
+ border: 1px solid var(--color-border);
1405
+ border-radius: 3px;
1406
+ padding: 0 4px;
1407
+ color: var(--color-text-muted);
1408
+ }
1409
+
1410
+ .content-nav--hidden + .content-nav-show-btn {
1411
+ display: block;
1412
+ }
1413
+
1414
+ /* Add scroll-margin to headings so they clear the sticky nav */
1415
+ .markdown-rendered h1,
1416
+ .markdown-rendered h2,
1417
+ .markdown-rendered h3,
1418
+ .markdown-rendered h4,
1419
+ .markdown-rendered h5,
1420
+ .markdown-rendered h6 {
1421
+ scroll-margin-top: calc(var(--nav-height) + var(--space-lg));
1422
+ }
1423
+
1424
+ /* Hide sidebar on small screens */
1425
+ @media (max-width: 1024px) {
1426
+ .content-nav {
1427
+ display: none;
1428
+ }
1429
+ .content-nav-show-btn {
1430
+ display: none !important;
1431
+ }
1432
+ }
@@ -0,0 +1,45 @@
1
+ module CoPlan
2
+ class PlanPresenceChannel < ActionCable::Channel::Base
3
+ def subscribed
4
+ @plan = Plan.find_by(id: params[:plan_id])
5
+ policy = @plan && PlanPolicy.new(current_user, @plan)
6
+ unless @plan && policy.show?
7
+ reject
8
+ return
9
+ end
10
+
11
+ PlanViewer.track(plan: @plan, user: current_user)
12
+ broadcast_viewers
13
+ end
14
+
15
+ def unsubscribed
16
+ return unless @plan
17
+
18
+ PlanViewer.expire(plan: @plan, user: current_user)
19
+ broadcast_viewers
20
+ end
21
+
22
+ def ping
23
+ return unless @plan
24
+
25
+ PlanViewer.track(plan: @plan, user: current_user)
26
+ broadcast_viewers
27
+ end
28
+
29
+ private
30
+
31
+ def current_user
32
+ connection.current_user
33
+ end
34
+
35
+ def broadcast_viewers
36
+ viewers = PlanViewer.active_viewers_for(@plan)
37
+ Broadcaster.replace_to(
38
+ @plan,
39
+ target: "plan-viewers",
40
+ partial: "coplan/plans/viewers",
41
+ locals: { viewers: viewers }
42
+ )
43
+ end
44
+ end
45
+ end
@@ -34,7 +34,7 @@ module CoPlan
34
34
  }, status: :created
35
35
 
36
36
  rescue ActiveRecord::RecordInvalid => e
37
- render json: { error: e.message }, status: :unprocessable_entity
37
+ render json: { error: e.message }, status: :unprocessable_content
38
38
  end
39
39
 
40
40
  def resolve
@@ -98,7 +98,7 @@ module CoPlan
98
98
  }, status: :created
99
99
 
100
100
  rescue ActiveRecord::RecordInvalid => e
101
- render json: { error: e.message }, status: :unprocessable_entity
101
+ render json: { error: e.message }, status: :unprocessable_content
102
102
  end
103
103
 
104
104
  private
@@ -10,12 +10,12 @@ module CoPlan
10
10
  base_revision = params[:base_revision]&.to_i
11
11
 
12
12
  unless base_revision.present?
13
- render json: { error: "base_revision is required" }, status: :unprocessable_entity
13
+ render json: { error: "base_revision is required" }, status: :unprocessable_content
14
14
  return
15
15
  end
16
16
 
17
17
  unless operations.is_a?(Array) && operations.any?
18
- render json: { error: "operations must be a non-empty array" }, status: :unprocessable_entity
18
+ render json: { error: "operations must be a non-empty array" }, status: :unprocessable_content
19
19
  return
20
20
  end
21
21
 
@@ -27,7 +27,7 @@ module CoPlan
27
27
  apply_direct(operations, base_revision)
28
28
  end
29
29
  rescue Plans::OperationError => e
30
- render json: { error: e.message }, status: :unprocessable_entity
30
+ render json: { error: e.message }, status: :unprocessable_content
31
31
  end
32
32
 
33
33
  private
@@ -32,7 +32,7 @@ module CoPlan
32
32
  current_revision: plan.current_revision
33
33
  ), status: :created
34
34
  rescue ActiveRecord::RecordInvalid => e
35
- render json: { error: e.message }, status: :unprocessable_entity
35
+ render json: { error: e.message }, status: :unprocessable_content
36
36
  end
37
37
 
38
38
  def update
@@ -61,7 +61,7 @@ module CoPlan
61
61
  current_revision: @plan.current_revision
62
62
  )
63
63
  rescue ActiveRecord::RecordInvalid => e
64
- render json: { error: e.record.errors.full_messages.join(", ") }, status: :unprocessable_entity
64
+ render json: { error: e.record.errors.full_messages.join(", ") }, status: :unprocessable_content
65
65
  end
66
66
 
67
67
  def versions
@@ -11,7 +11,7 @@ module CoPlan
11
11
  def create
12
12
  actor_type = params[:actor_type].presence || ApiToken::HOLDER_TYPE
13
13
  unless EditSession::ACTOR_TYPES.include?(actor_type)
14
- render json: { error: "Invalid actor_type" }, status: :unprocessable_entity
14
+ render json: { error: "Invalid actor_type" }, status: :unprocessable_content
15
15
  return
16
16
  end
17
17
  ttl = actor_type == "cloud_persona" ? EditSession::CLOUD_PERSONA_TTL : EditSession::LOCAL_AGENT_TTL
@@ -56,7 +56,7 @@ module CoPlan
56
56
 
57
57
  render json: response
58
58
  rescue Plans::CommitSession::SessionNotOpenError => e
59
- render json: { error: e.message }, status: :unprocessable_entity
59
+ render json: { error: e.message }, status: :unprocessable_content
60
60
  rescue Plans::CommitSession::StaleSessionError => e
61
61
  render json: { error: e.message }, status: :conflict
62
62
  rescue Plans::CommitSession::SessionConflictError => e
@@ -11,6 +11,7 @@ module CoPlan
11
11
  def show
12
12
  authorize!(@plan, :show?)
13
13
  @threads = @plan.comment_threads.includes(:comments, :created_by_user).order(:created_at)
14
+ PlanViewer.track(plan: @plan, user: current_user)
14
15
  end
15
16
 
16
17
  def edit
@@ -20,7 +20,7 @@ module CoPlan
20
20
  rescue ActiveRecord::RecordInvalid => e
21
21
  @api_tokens = current_user.api_tokens.order(created_at: :desc)
22
22
  flash.now[:alert] = e.message
23
- render :index, status: :unprocessable_entity
23
+ render :index, status: :unprocessable_content
24
24
  end
25
25
 
26
26
  def destroy
@@ -1,5 +1,6 @@
1
1
  module CoPlan
2
2
  module ApplicationHelper
3
+ include MarkdownHelper
3
4
  FAVICON_COLORS = {
4
5
  "production" => { start: "#3B82F6", stop: "#1E40AF" },
5
6
  "staging" => { start: "#F59E0B", stop: "#D97706" },
@@ -37,6 +38,18 @@ module CoPlan
37
38
  tag.link(rel: "icon", type: "image/svg+xml", href: data_uri)
38
39
  end
39
40
 
41
+ def plan_og_description(plan)
42
+ status = plan.status.capitalize
43
+ author = plan.created_by_user.name
44
+ prefix = "#{status} · by #{author}"
45
+ content = plan.current_content
46
+ return prefix if content.blank?
47
+
48
+ plain = markdown_to_plain_text(content)
49
+ truncated = truncate(plain, length: 200, omission: "…")
50
+ "#{prefix} — #{truncated}"
51
+ end
52
+
40
53
  def coplan_environment_badge
41
54
  return if Rails.env.production?
42
55
 
@@ -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
@@ -17,11 +17,16 @@ module CoPlan
17
17
  ALLOWED_ATTRIBUTES = %w[id class href src alt title].freeze
18
18
 
19
19
  def render_markdown(content)
20
- 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 })
21
21
  sanitized = sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
22
22
  tag.div(sanitized, class: "markdown-rendered")
23
23
  end
24
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
+
25
30
  def render_line_view(content)
26
31
  lines = content.to_s.split("\n", -1)
27
32
  line_divs = lines.each_with_index.map do |line, index|