coplan-engine 0.1.3 → 0.4.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 (28) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/coplan/application.css +268 -85
  3. data/app/controllers/coplan/api/v1/comments_controller.rb +7 -7
  4. data/app/controllers/coplan/api/v1/operations_controller.rb +38 -0
  5. data/app/controllers/coplan/comment_threads_controller.rb +26 -72
  6. data/app/controllers/coplan/plans_controller.rb +1 -3
  7. data/app/helpers/coplan/application_helper.rb +44 -0
  8. data/app/helpers/coplan/markdown_helper.rb +1 -0
  9. data/app/javascript/controllers/coplan/comment_form_controller.js +14 -0
  10. data/app/javascript/controllers/coplan/comment_nav_controller.js +247 -0
  11. data/app/javascript/controllers/coplan/text_selection_controller.js +143 -169
  12. data/app/jobs/coplan/automated_review_job.rb +4 -4
  13. data/app/models/coplan/comment_thread.rb +13 -7
  14. data/app/policies/coplan/comment_thread_policy.rb +1 -1
  15. data/app/services/coplan/plans/apply_operations.rb +43 -0
  16. data/app/services/coplan/plans/commit_session.rb +26 -1
  17. data/app/services/coplan/plans/position_resolver.rb +111 -0
  18. data/app/views/coplan/comment_threads/_new_comment_form.html.erb +2 -1
  19. data/app/views/coplan/comment_threads/_reply_form.html.erb +2 -1
  20. data/app/views/coplan/comment_threads/_thread.html.erb +3 -6
  21. data/app/views/coplan/comment_threads/_thread_popover.html.erb +58 -0
  22. data/app/views/coplan/plans/show.html.erb +22 -30
  23. data/app/views/layouts/coplan/application.html.erb +5 -0
  24. data/config/routes.rb +2 -2
  25. data/db/migrate/20260320145453_migrate_comment_thread_statuses.rb +31 -0
  26. data/lib/coplan/version.rb +1 -1
  27. metadata +5 -2
  28. data/app/javascript/controllers/coplan/tabs_controller.js +0 -18
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 129983ca7890cb3869a1c594ebde86720d3ba80e14861b9ad613c3311eec30b0
4
- data.tar.gz: 93ee7fa05f6f3676bc414a9f9794a3a04c80cee7181efcab89d484816f72cd79
3
+ metadata.gz: fc0c571fae079ecc474da55dea08af61a198ca761f6f3c9b5c3b672afe3b838c
4
+ data.tar.gz: 791565182e62dab38561ae29eeb9794cf0f0087222449b4ef5842525b5e28424
5
5
  SHA512:
6
- metadata.gz: 291848526406f7b918dd2cf86e161ccec3e85eecf73277325af84fb320ea78b4fd64c260c0f539ba6bf0e59b747906c2d173fdf39c1920c72522537ae62fe0ae
7
- data.tar.gz: 8f5af631ce15ede7498305751f51a89dfc71136448196a959e4aa3223e5c00fde1955645aefa507db117f72269d8ab256d7bad1053c56cb34a218b509e9cd6e6
6
+ metadata.gz: 973c44900f288a2ba876c9ee2df96e47e13668f271c3b5e1de187ddf4d935b4fef63de2dac2753cf1acc82628a4a4e3987c8e8e3ea8cd3e38a9ecd123e7b734e
7
+ data.tar.gz: e7866646881f7c217cb5b23e0adac89651e6fa47facfe9f4f33049a4e2a7362f8eb209f40dc161dbd43bdd689e643248c050be0044821efeae6abcb97c13aa08
@@ -31,7 +31,7 @@
31
31
  --space-2xl: 3rem;
32
32
 
33
33
  /* Typography */
34
- --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
34
+ --font-sans: 'Lexend', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
35
35
  --font-mono: "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace;
36
36
  --text-sm: 0.875rem;
37
37
  --text-base: 1rem;
@@ -119,6 +119,17 @@ img, svg {
119
119
  border-radius: 6px;
120
120
  }
121
121
 
122
+ .env-badge {
123
+ font-size: 10px;
124
+ font-weight: 600;
125
+ color: #fff;
126
+ padding: 1px 6px;
127
+ border-radius: 4px;
128
+ text-transform: uppercase;
129
+ letter-spacing: 0.5px;
130
+ line-height: 1.4;
131
+ }
132
+
122
133
  .site-nav__brand:hover {
123
134
  text-decoration: none;
124
135
  color: var(--color-primary);
@@ -268,6 +279,10 @@ img, svg {
268
279
  .badge--developing { background: #dbeafe; color: var(--color-status-developing); }
269
280
  .badge--live { background: #d1fae5; color: var(--color-status-live); }
270
281
  .badge--abandoned { background: #f3f4f6; color: var(--color-status-abandoned); }
282
+ .badge--pending { background: #fef3c7; color: var(--color-status-considering); }
283
+ .badge--todo { background: #dbeafe; color: var(--color-status-developing); }
284
+ .badge--discarded { background: #f3f4f6; color: var(--color-status-abandoned); }
285
+ .badge--resolved { background: #d1fae5; color: var(--color-status-live); }
271
286
 
272
287
  /* Forms */
273
288
  .form-group {
@@ -514,7 +529,7 @@ img, svg {
514
529
 
515
530
  .markdown-rendered p {
516
531
  margin-bottom: var(--space-md);
517
- line-height: 1.7;
532
+ line-height: 1.9;
518
533
  }
519
534
 
520
535
  .markdown-rendered ul,
@@ -525,7 +540,7 @@ img, svg {
525
540
 
526
541
  .markdown-rendered li {
527
542
  margin-bottom: var(--space-xs);
528
- line-height: 1.7;
543
+ line-height: 1.9;
529
544
  }
530
545
 
531
546
  .markdown-rendered pre {
@@ -605,20 +620,55 @@ img, svg {
605
620
 
606
621
  /* Anchor text highlights */
607
622
  .anchor-highlight {
608
- background: rgba(255, 213, 79, 0.3);
609
- border-bottom: 2px solid rgba(255, 179, 0, 0.5);
610
623
  cursor: pointer;
611
624
  transition: background 0.2s;
612
625
  }
613
626
 
614
- .anchor-highlight:hover {
615
- background: rgba(255, 213, 79, 0.5);
627
+ .anchor-highlight--open {
628
+ background: rgba(108, 140, 255, 0.12);
629
+ border-bottom: 2px solid rgba(108, 140, 255, 0.6);
630
+ cursor: pointer;
631
+ }
632
+
633
+ .anchor-highlight--open:hover {
634
+ background: rgba(108, 140, 255, 0.22);
635
+ }
636
+
637
+ .anchor-highlight--pending {
638
+ background: rgba(245, 158, 11, 0.12);
639
+ border-bottom: 2px solid rgba(245, 158, 11, 0.6);
640
+ }
641
+
642
+ .anchor-highlight--pending:hover {
643
+ background: rgba(245, 158, 11, 0.22);
644
+ }
645
+
646
+ .anchor-highlight--todo {
647
+ background: rgba(59, 130, 246, 0.12);
648
+ border-bottom: 2px solid rgba(59, 130, 246, 0.6);
649
+ }
650
+
651
+ .anchor-highlight--todo:hover {
652
+ background: rgba(59, 130, 246, 0.22);
653
+ }
654
+
655
+ .anchor-highlight--resolved {
656
+ background: none;
657
+ border-bottom: none;
658
+ cursor: default;
659
+ pointer-events: none;
660
+ }
661
+
662
+ .plan-layout--show-resolved .anchor-highlight--resolved {
663
+ border-bottom: 1px dashed var(--color-text-muted);
664
+ cursor: pointer;
665
+ pointer-events: auto;
616
666
  }
617
667
 
618
668
  .anchor-highlight--active {
619
- background: rgba(255, 179, 0, 0.4);
620
- border-bottom: 2px solid var(--color-warning);
621
- outline: 2px solid rgba(255, 179, 0, 0.3);
669
+ background: rgba(108, 140, 255, 0.25);
670
+ border-bottom: 2px solid var(--color-primary);
671
+ outline: 2px solid rgba(108, 140, 255, 0.3);
622
672
  outline-offset: 1px;
623
673
  }
624
674
 
@@ -653,87 +703,35 @@ img, svg {
653
703
  border-radius: 0 var(--radius) var(--radius) 0;
654
704
  }
655
705
 
656
- /* Plan layout (content + sidebar) */
706
+ /* Plan layout */
657
707
  .plan-layout {
658
708
  position: relative;
659
- }
660
-
661
- .plan-layout__content {
662
- max-width: 60%;
663
- position: relative;
664
- }
665
-
666
- .plan-layout__sidebar {
667
- position: absolute;
668
- top: 0;
669
- right: 0;
670
- width: 22rem;
671
- }
672
-
673
- @media (max-width: 900px) {
674
- .plan-layout__content {
675
- max-width: 100%;
676
- }
677
-
678
- .plan-layout__sidebar {
679
- position: static;
680
- width: 100%;
681
- margin-top: var(--space-lg);
682
- }
683
- }
684
- .sidebar-heading {
685
- font-size: var(--text-lg);
686
- font-weight: 700;
687
- margin-bottom: var(--space-md);
688
- }
689
-
690
- /* Comment tabs */
691
- .comment-tabs__nav {
692
709
  display: flex;
693
710
  gap: 0;
694
- border-bottom: 2px solid var(--color-border);
695
- margin-bottom: var(--space-md);
696
711
  }
697
712
 
698
- .comment-tab {
699
- padding: var(--space-xs) var(--space-md);
700
- font-size: var(--text-sm);
701
- font-weight: 600;
702
- color: var(--color-text-muted);
703
- background: none;
704
- border: none;
705
- border-bottom: 2px solid transparent;
706
- margin-bottom: -2px;
707
- cursor: pointer;
708
- }
709
-
710
- .comment-tab:hover {
711
- color: var(--color-text);
712
- }
713
-
714
- .comment-tab--active {
715
- color: var(--color-primary);
716
- border-bottom-color: var(--color-primary);
717
- }
718
-
719
- .comment-tab__count {
720
- background: var(--color-border);
721
- color: var(--color-text-muted);
722
- font-size: 0.7rem;
723
- padding: 1px 6px;
724
- border-radius: 10px;
725
- margin-left: 4px;
713
+ .plan-layout__margin {
714
+ width: 40px;
715
+ flex-shrink: 0;
716
+ position: relative;
726
717
  }
727
718
 
728
- .comment-tab--active .comment-tab__count {
729
- background: var(--color-primary);
730
- color: white;
719
+ .plan-layout__content {
720
+ position: relative;
721
+ flex: 1;
722
+ min-width: 0;
731
723
  }
732
724
 
733
725
  /* Comment form */
734
726
  .comment-form {
735
- margin-bottom: var(--space-md);
727
+ position: absolute;
728
+ z-index: 100;
729
+ width: 360px;
736
730
  padding: var(--space-md);
731
+ background: var(--color-surface);
732
+ border: 1px solid var(--color-border);
733
+ border-radius: var(--radius);
734
+ box-shadow: var(--shadow-lg);
737
735
  }
738
736
 
739
737
  .comment-form .form-group {
@@ -757,13 +755,6 @@ img, svg {
757
755
  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
758
756
  }
759
757
 
760
- /* Comment threads list */
761
- .comment-threads-list {
762
- display: flex;
763
- flex-direction: column;
764
- gap: var(--space-md);
765
- }
766
-
767
758
  /* Comment thread */
768
759
  .comment-thread {
769
760
  padding: var(--space-md);
@@ -960,3 +951,195 @@ img, svg {
960
951
  color: var(--color-text-muted);
961
952
  margin-top: var(--space-xs);
962
953
  }
954
+
955
+ /* Thread popover data containers (hidden, provide data for JS) */
956
+ .thread-popover-data {
957
+ display: contents;
958
+ }
959
+
960
+ /* Thread popover (native HTML Popover API) */
961
+ .thread-popover {
962
+ position: fixed;
963
+ margin: 0;
964
+ padding: var(--space-md);
965
+ background: var(--color-surface);
966
+ border: 1px solid var(--color-border);
967
+ border-radius: var(--radius);
968
+ box-shadow: var(--shadow-lg);
969
+ width: 380px;
970
+ max-height: 60vh;
971
+ overflow-y: auto;
972
+ z-index: 200;
973
+ }
974
+
975
+ .thread-popover::backdrop {
976
+ background: transparent;
977
+ }
978
+
979
+ .thread-popover__header {
980
+ display: flex;
981
+ gap: var(--space-xs);
982
+ align-items: center;
983
+ margin-bottom: var(--space-sm);
984
+ }
985
+
986
+ .thread-popover__quote {
987
+ border-left: 3px solid rgba(108, 140, 255, 0.6);
988
+ padding: var(--space-xs) var(--space-sm);
989
+ margin: 0 0 var(--space-sm) 0;
990
+ font-size: var(--text-sm);
991
+ color: var(--color-text-muted);
992
+ background: rgba(108, 140, 255, 0.05);
993
+ border-radius: 0 var(--radius) var(--radius) 0;
994
+ }
995
+
996
+ .thread-popover__comments {
997
+ display: flex;
998
+ flex-direction: column;
999
+ gap: var(--space-xs);
1000
+ }
1001
+
1002
+ .thread-popover__reply {
1003
+ margin-top: var(--space-sm);
1004
+ padding-top: var(--space-sm);
1005
+ border-top: 1px solid var(--color-border);
1006
+ }
1007
+
1008
+ .thread-popover__reply textarea {
1009
+ width: 100%;
1010
+ padding: var(--space-sm);
1011
+ font-family: var(--font-sans);
1012
+ font-size: var(--text-sm);
1013
+ border: 1px solid var(--color-border);
1014
+ border-radius: var(--radius);
1015
+ resize: vertical;
1016
+ min-height: 3rem;
1017
+ }
1018
+
1019
+ .thread-popover__reply textarea:focus {
1020
+ outline: none;
1021
+ border-color: var(--color-primary);
1022
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
1023
+ }
1024
+
1025
+ .thread-popover__reply .form-group {
1026
+ margin-bottom: var(--space-xs);
1027
+ }
1028
+
1029
+ .thread-popover__actions {
1030
+ display: flex;
1031
+ gap: var(--space-xs);
1032
+ margin-top: var(--space-sm);
1033
+ padding-top: var(--space-sm);
1034
+ border-top: 1px solid var(--color-border);
1035
+ }
1036
+
1037
+ /* Margin dots */
1038
+ .margin-dot {
1039
+ position: absolute;
1040
+ width: 10px;
1041
+ height: 10px;
1042
+ border-radius: 50%;
1043
+ border: 2px solid transparent;
1044
+ background-clip: padding-box;
1045
+ cursor: pointer;
1046
+ left: 14px;
1047
+ transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
1048
+ display: flex;
1049
+ align-items: center;
1050
+ justify-content: center;
1051
+ padding: 0;
1052
+ outline: none;
1053
+ }
1054
+
1055
+ .margin-dot::after {
1056
+ content: "";
1057
+ position: absolute;
1058
+ inset: -8px;
1059
+ border-radius: 50%;
1060
+ }
1061
+
1062
+ .margin-dot:hover {
1063
+ transform: scale(1.4);
1064
+ }
1065
+
1066
+ .margin-dot:focus-visible {
1067
+ outline: 2px solid var(--color-primary);
1068
+ outline-offset: 2px;
1069
+ }
1070
+
1071
+ .margin-dot--pending {
1072
+ background: var(--color-status-considering);
1073
+ border-color: rgba(245, 158, 11, 0.3);
1074
+ box-shadow: 0 0 6px rgba(245, 158, 11, 0.25);
1075
+ }
1076
+
1077
+ .margin-dot--todo {
1078
+ background: var(--color-status-developing);
1079
+ border-color: rgba(59, 130, 246, 0.3);
1080
+ box-shadow: 0 0 6px rgba(59, 130, 246, 0.25);
1081
+ }
1082
+
1083
+ .margin-dot--resolved {
1084
+ background: var(--color-text-muted);
1085
+ opacity: 0.4;
1086
+ display: none;
1087
+ }
1088
+
1089
+ .plan-layout--show-resolved .margin-dot--resolved {
1090
+ display: flex;
1091
+ }
1092
+
1093
+ /* Ensure content doesn't get hidden behind fixed toolbar */
1094
+ body:has(.comment-toolbar) .main-content {
1095
+ padding-bottom: 3.5rem;
1096
+ }
1097
+
1098
+ /* Comment toolbar (bottom-pinned) */
1099
+ .comment-toolbar {
1100
+ position: fixed;
1101
+ bottom: 0;
1102
+ left: 0;
1103
+ right: 0;
1104
+ background: var(--color-surface);
1105
+ border-top: 1px solid var(--color-border);
1106
+ padding: var(--space-sm) var(--space-lg);
1107
+ display: flex;
1108
+ align-items: center;
1109
+ gap: var(--space-lg);
1110
+ z-index: 50;
1111
+ justify-content: center;
1112
+ box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
1113
+ }
1114
+
1115
+ .comment-toolbar__count {
1116
+ font-size: var(--text-sm);
1117
+ font-weight: 600;
1118
+ color: var(--color-text);
1119
+ }
1120
+
1121
+ .comment-toolbar__nav {
1122
+ display: flex;
1123
+ align-items: center;
1124
+ gap: var(--space-xs);
1125
+ }
1126
+
1127
+ .comment-toolbar__position {
1128
+ font-size: var(--text-sm);
1129
+ color: var(--color-text-muted);
1130
+ min-width: 4rem;
1131
+ text-align: center;
1132
+ }
1133
+
1134
+ .comment-toolbar__toggle {
1135
+ font-size: var(--text-sm);
1136
+ color: var(--color-text-muted);
1137
+ cursor: pointer;
1138
+ display: flex;
1139
+ align-items: center;
1140
+ gap: var(--space-xs);
1141
+ }
1142
+
1143
+ .comment-toolbar__toggle input[type="checkbox"] {
1144
+ cursor: pointer;
1145
+ }
@@ -56,7 +56,7 @@ module CoPlan
56
56
  render json: { thread_id: thread.id, status: thread.status }
57
57
  end
58
58
 
59
- def dismiss
59
+ def discard
60
60
  thread = @plan.comment_threads.find_by(id: params[:id])
61
61
  unless thread
62
62
  render json: { error: "Comment thread not found" }, status: :not_found
@@ -64,12 +64,12 @@ module CoPlan
64
64
  end
65
65
 
66
66
  policy = CommentThreadPolicy.new(current_user, thread)
67
- unless policy.dismiss?
67
+ unless policy.discard?
68
68
  render json: { error: "Not authorized" }, status: :forbidden
69
69
  return
70
70
  end
71
71
 
72
- thread.dismiss!(current_user)
72
+ thread.discard!(current_user)
73
73
  broadcast_thread_update(thread)
74
74
 
75
75
  render json: { thread_id: thread.id, status: thread.status }
@@ -104,10 +104,10 @@ module CoPlan
104
104
  private
105
105
 
106
106
  def broadcast_new_thread(thread)
107
- Broadcaster.prepend_to(
107
+ Broadcaster.append_to(
108
108
  @plan,
109
- target: "comment-threads",
110
- partial: "coplan/comment_threads/thread",
109
+ target: "plan-threads",
110
+ partial: "coplan/comment_threads/thread_popover",
111
111
  locals: { thread: thread, plan: @plan }
112
112
  )
113
113
  end
@@ -116,7 +116,7 @@ module CoPlan
116
116
  Broadcaster.replace_to(
117
117
  @plan,
118
118
  target: ActionView::RecordIdentifier.dom_id(thread),
119
- partial: "coplan/comment_threads/thread",
119
+ partial: "coplan/comment_threads/thread_popover",
120
120
  locals: { thread: thread, plan: @plan }
121
121
  )
122
122
  end
@@ -281,6 +281,44 @@ module CoPlan
281
281
  return
282
282
  end
283
283
  end
284
+ when "replace_section"
285
+ return unless op["heading"]
286
+ include_heading = op.fetch("include_heading", true)
287
+ include_heading = include_heading != false && include_heading != "false"
288
+
289
+ transformed_ranges.each do |tr|
290
+ if include_heading
291
+ # Verify the heading is the first line of the section range
292
+ first_line_end = content.index("\n", tr[0]) || tr[1]
293
+ first_line = content[tr[0]...[first_line_end, tr[1]].min]
294
+ unless first_line&.rstrip == op["heading"]&.rstrip
295
+ render json: {
296
+ error: "Conflict: section at target position has changed",
297
+ current_revision: @plan.current_revision,
298
+ expected_heading: op["heading"],
299
+ found: content[tr[0]...tr[1]]&.slice(0, 200)
300
+ }, status: :conflict
301
+ return
302
+ end
303
+ else
304
+ # Body-only: verify the heading appears on the line before tr[0].
305
+ # Walk backwards past any blank lines to find the heading text.
306
+ search_pos = tr[0]
307
+ search_pos -= 1 while search_pos > 0 && content[search_pos - 1] == "\n"
308
+ heading_line_end = search_pos
309
+ heading_line_start = search_pos > 0 ? (content.rindex("\n", search_pos - 1) || -1) + 1 : 0
310
+ heading_text = content[heading_line_start...heading_line_end]
311
+ unless heading_text == op["heading"]
312
+ render json: {
313
+ error: "Conflict: section heading before target position has changed",
314
+ current_revision: @plan.current_revision,
315
+ expected_heading: op["heading"],
316
+ found: heading_text
317
+ }, status: :conflict
318
+ return
319
+ end
320
+ end
321
+ end
284
322
  end
285
323
  end
286
324
 
@@ -3,11 +3,15 @@ module CoPlan
3
3
  include ActionView::RecordIdentifier
4
4
 
5
5
  before_action :set_plan
6
- before_action :set_thread, only: [:resolve, :accept, :dismiss, :reopen]
6
+ before_action :set_thread, only: [:resolve, :accept, :discard, :reopen]
7
7
 
8
8
  def create
9
9
  authorize!(@plan, :show?)
10
10
 
11
+ # Author's own comments start as "todo" (self-assigned work item);
12
+ # non-author comments start as "pending" (awaiting author triage).
13
+ initial_status = current_user.id == @plan.created_by_user_id ? "todo" : "pending"
14
+
11
15
  thread = @plan.comment_threads.new(
12
16
  plan_version: @plan.current_plan_version,
13
17
  anchor_text: params[:comment_thread][:anchor_text].presence,
@@ -15,7 +19,8 @@ module CoPlan
15
19
  anchor_occurrence: params[:comment_thread][:anchor_occurrence].presence&.to_i,
16
20
  start_line: params[:comment_thread][:start_line].presence,
17
21
  end_line: params[:comment_thread][:end_line].presence,
18
- created_by_user: current_user
22
+ created_by_user: current_user,
23
+ status: initial_status
19
24
  )
20
25
 
21
26
  thread.save!
@@ -26,8 +31,14 @@ module CoPlan
26
31
  body_markdown: params[:comment_thread][:body_markdown]
27
32
  )
28
33
 
29
- broadcast_new_thread(thread)
30
- broadcast_tab_counts
34
+ if thread.anchored?
35
+ Broadcaster.append_to(
36
+ @plan,
37
+ target: "plan-threads",
38
+ partial: "coplan/comment_threads/thread_popover",
39
+ locals: { thread: thread, plan: @plan }
40
+ )
41
+ end
31
42
 
32
43
  respond_with_stream_or_redirect("Comment added.")
33
44
  end
@@ -35,34 +46,28 @@ module CoPlan
35
46
  def resolve
36
47
  authorize!(@thread, :resolve?)
37
48
  @thread.resolve!(current_user)
38
- broadcast_thread_move(@thread, from: "comment-threads", to: "resolved-comment-threads")
49
+ broadcast_thread_replace(@thread)
39
50
  respond_with_stream_or_redirect("Thread resolved.")
40
51
  end
41
52
 
42
53
  def accept
43
54
  authorize!(@thread, :accept?)
44
55
  @thread.accept!(current_user)
45
- broadcast_thread_move(@thread, from: "comment-threads", to: "resolved-comment-threads")
56
+ broadcast_thread_replace(@thread)
46
57
  respond_with_stream_or_redirect("Thread accepted.")
47
58
  end
48
59
 
49
- def dismiss
50
- authorize!(@thread, :dismiss?)
51
- @thread.dismiss!(current_user)
52
- broadcast_thread_move(@thread, from: "comment-threads", to: "resolved-comment-threads")
53
- respond_with_stream_or_redirect("Thread dismissed.")
60
+ def discard
61
+ authorize!(@thread, :discard?)
62
+ @thread.discard!(current_user)
63
+ broadcast_thread_replace(@thread)
64
+ respond_with_stream_or_redirect("Thread discarded.")
54
65
  end
55
66
 
56
67
  def reopen
57
68
  authorize!(@thread, :reopen?)
58
- @thread.update!(status: "open", resolved_by_user: nil)
59
- # Out-of-date threads stay in the archived list even when reopened,
60
- # since the active scope excludes out_of_date rows.
61
- if @thread.out_of_date?
62
- broadcast_thread_replace(@thread)
63
- else
64
- broadcast_thread_move(@thread, from: "resolved-comment-threads", to: "comment-threads")
65
- end
69
+ @thread.update!(status: "pending", resolved_by_user: nil)
70
+ broadcast_thread_replace(@thread)
66
71
  respond_with_stream_or_redirect("Thread reopened.")
67
72
  end
68
73
 
@@ -76,15 +81,6 @@ module CoPlan
76
81
  @thread = @plan.comment_threads.find(params[:id])
77
82
  end
78
83
 
79
- def broadcast_new_thread(thread)
80
- Broadcaster.prepend_to(
81
- @plan,
82
- target: "comment-threads",
83
- partial: "coplan/comment_threads/thread",
84
- locals: { thread: thread, plan: @plan }
85
- )
86
- end
87
-
88
84
  # Broadcasts update all clients (including the submitter) via WebSocket.
89
85
  # The empty turbo_stream response prevents Turbo from navigating (which causes scroll-to-top).
90
86
  def respond_with_stream_or_redirect(message)
@@ -94,56 +90,14 @@ module CoPlan
94
90
  end
95
91
  end
96
92
 
97
- # Replaces a thread in place (status changed but stays in the same list).
93
+ # Replaces a thread in place (status changed).
98
94
  def broadcast_thread_replace(thread)
99
95
  Broadcaster.replace_to(
100
96
  @plan,
101
97
  target: dom_id(thread),
102
- partial: "coplan/comment_threads/thread",
98
+ partial: "coplan/comment_threads/thread_popover",
103
99
  locals: { thread: thread, plan: @plan }
104
100
  )
105
- broadcast_tab_counts
106
- end
107
-
108
- # Moves a thread between Open/Resolved lists and updates tab counts.
109
- def broadcast_thread_move(thread, from:, to:)
110
- Broadcaster.remove_to(@plan, target: dom_id(thread))
111
- Broadcaster.append_to(
112
- @plan,
113
- target: to,
114
- partial: "coplan/comment_threads/thread",
115
- locals: { thread: thread, plan: @plan }
116
- )
117
- broadcast_tab_counts
118
- end
119
-
120
- def broadcast_tab_counts
121
- threads = @plan.comment_threads
122
- open_count = threads.active.count
123
- resolved_count = threads.archived.count
124
-
125
- Broadcaster.update_to(
126
- @plan,
127
- target: "open-thread-count",
128
- html: open_count > 0 ? open_count.to_s : ""
129
- )
130
- Broadcaster.update_to(
131
- @plan,
132
- target: "resolved-thread-count",
133
- html: resolved_count > 0 ? resolved_count.to_s : ""
134
- )
135
-
136
- # Toggle empty-state placeholders
137
- Broadcaster.replace_to(
138
- @plan,
139
- target: "open-threads-empty",
140
- html: %(<p class="text-sm text-muted" id="open-threads-empty" #{'style="display: none;"' if open_count > 0}>No open comments.</p>)
141
- )
142
- Broadcaster.replace_to(
143
- @plan,
144
- target: "resolved-threads-empty",
145
- html: %(<p class="text-sm text-muted" id="resolved-threads-empty" #{'style="display: none;"' if resolved_count > 0}>No resolved comments.</p>)
146
- )
147
101
  end
148
102
  end
149
103
  end
@@ -10,9 +10,7 @@ module CoPlan
10
10
 
11
11
  def show
12
12
  authorize!(@plan, :show?)
13
- threads = @plan.comment_threads.includes(:comments, :created_by_user, :plan_version).order(created_at: :asc)
14
- @active_threads = threads.active
15
- @archived_threads = threads.archived
13
+ @threads = @plan.comment_threads.includes(:comments, :created_by_user).order(:created_at)
16
14
  end
17
15
 
18
16
  def edit