collavre 0.8.0 → 0.8.2

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/collavre/actiontext.css +3 -3
  3. data/app/assets/stylesheets/collavre/comments_popup.css +44 -31
  4. data/app/assets/stylesheets/collavre/creatives.css +25 -16
  5. data/app/assets/stylesheets/collavre/dark_mode.css +56 -3
  6. data/app/assets/stylesheets/collavre/design_tokens.css +10 -2
  7. data/app/assets/stylesheets/collavre/mention_menu.css +2 -2
  8. data/app/assets/stylesheets/collavre/popup.css +82 -18
  9. data/app/controllers/collavre/creative_shares_controller.rb +27 -0
  10. data/app/javascript/components/creative_tree_row.js +11 -4
  11. data/app/javascript/controllers/comments/contexts_controller.js +7 -4
  12. data/app/javascript/controllers/comments/popup_controller.js +6 -0
  13. data/app/javascript/controllers/comments/topics_controller.js +9 -6
  14. data/app/javascript/controllers/creatives/expansion_controller.js +4 -4
  15. data/app/javascript/controllers/creatives/tree_controller.js +3 -1
  16. data/app/javascript/controllers/share_modal_controller.js +57 -0
  17. data/app/services/collavre/ai_agent/approval_handler.rb +7 -3
  18. data/app/services/collavre/ai_agent_service.rb +16 -3
  19. data/app/services/collavre/ai_client.rb +22 -6
  20. data/app/services/collavre/tools/creative_retrieval_service.rb +2 -2
  21. data/app/services/collavre/tools/creative_update_service.rb +1 -1
  22. data/app/views/collavre/comments/_comments_popup.html.erb +1 -1
  23. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +1 -1
  24. data/app/views/collavre/creatives/_share_modal.html.erb +71 -28
  25. data/app/views/collavre/creatives/index.html.erb +2 -2
  26. data/app/views/collavre/shared/_custom_theme_style.html.erb +21 -0
  27. data/app/views/collavre/users/new_ai.html.erb +1 -1
  28. data/app/views/layouts/collavre/slide.html.erb +1 -17
  29. data/config/locales/ai_agent.en.yml +10 -0
  30. data/config/locales/ai_agent.ko.yml +10 -0
  31. data/config/locales/creatives.en.yml +3 -1
  32. data/config/locales/creatives.ko.yml +3 -1
  33. data/lib/collavre/version.rb +1 -1
  34. metadata +2 -1
@@ -11,23 +11,33 @@
11
11
 
12
12
  /* Share popup specific tweaks */
13
13
  #share-creative-modal .popup-box {
14
- max-height: 80vh;
14
+ max-height: calc(100vh - 4em);
15
15
  overflow-y: auto;
16
16
  }
17
17
 
18
18
  #share-creative-modal .share-grid {
19
- max-height: 40vh;
19
+ max-height: 30vh;
20
20
  overflow-y: auto;
21
21
  }
22
22
 
23
+ @media (max-width: 768px) {
24
+ #share-creative-modal .popup-box {
25
+ max-width: calc(100vw - 1em) !important;
26
+ }
27
+
28
+ #share-creative-modal .share-grid {
29
+ max-height: 25vh;
30
+ }
31
+ }
32
+
23
33
  /* Ensure autocomplete popup inside share modal works without mention_menu.css */
24
34
  #share-creative-modal .common-popup {
25
35
  position: absolute;
26
- z-index: var(--layer-modal, 100);
27
- background: var(--surface-section, #fff);
28
- border: 1px solid var(--border-color, #ddd);
29
- box-shadow: var(--shadow-2, 0 2px 8px rgba(0,0,0,.15));
30
- border-radius: 6px;
36
+ z-index: var(--layer-modal);
37
+ background: var(--surface-section);
38
+ border: 1px solid var(--border-color);
39
+ box-shadow: var(--shadow-2);
40
+ border-radius: var(--radius-2);
31
41
  padding: 0.35em;
32
42
  max-width: min(420px, 90vw);
33
43
  }
@@ -45,7 +55,7 @@
45
55
  cursor: pointer;
46
56
  }
47
57
  #share-creative-modal .common-popup-list li:hover {
48
- background: var(--border-drag-over, #f0f0f0);
58
+ background: var(--surface-hover);
49
59
  }
50
60
 
51
61
  .share-grid {
@@ -89,7 +99,7 @@
89
99
  box-sizing: border-box;
90
100
  margin-bottom: 0.75em;
91
101
  padding: 0.5em;
92
- border-radius: 6px;
102
+ border-radius: var(--radius-2);
93
103
  border: 1px solid var(--border-color);
94
104
  background: var(--surface-bg);
95
105
  color: var(--text-primary);
@@ -102,7 +112,7 @@
102
112
  }
103
113
 
104
114
  .popup-box input:not([type="radio"]):not([type="checkbox"]),
105
- .popup-box select {
115
+ .popup-box select:not(.share-modal-permission-select) {
106
116
  width: 100%;
107
117
  box-sizing: border-box;
108
118
  }
@@ -151,27 +161,80 @@
151
161
  animation: share-modal-message-fade 4s ease-in-out;
152
162
  }
153
163
  .share-modal-message-success {
154
- background: var(--success-bg, #d4edda);
155
- color: var(--success-text, #155724);
156
- border: 1px solid var(--success-border, #c3e6cb);
164
+ background: color-mix(in srgb, var(--color-success) 12%, transparent);
165
+ color: var(--color-success);
166
+ border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent);
157
167
  }
158
168
  .share-modal-message-error {
159
- background: var(--danger-bg, #f8d7da);
160
- color: var(--danger-text, #721c24);
161
- border: 1px solid var(--danger-border, #f5c6cb);
169
+ background: color-mix(in srgb, var(--color-danger) 12%, transparent);
170
+ color: var(--color-danger);
171
+ border: 1px solid color-mix(in srgb, var(--color-danger) 30%, transparent);
162
172
  }
173
+
163
174
  @keyframes share-modal-message-fade {
164
175
  0%, 80% { opacity: 1; }
165
176
  100% { opacity: 0; }
166
177
  }
178
+
179
+ .share-modal-permission-select {
180
+ width: auto;
181
+ }
182
+
183
+ .share-modal-permission-select[disabled] {
184
+ opacity: 0.7;
185
+ cursor: default;
186
+ }
187
+
188
+ .share-inherited-link {
189
+ font-size: var(--text-0);
190
+ color: var(--text-muted);
191
+ white-space: nowrap;
192
+ }
193
+
194
+ .share-inherited-link:hover {
195
+ color: var(--text-link);
196
+ }
197
+
198
+ .share-inherited-short {
199
+ display: none;
200
+ }
201
+
202
+ @media (max-width: 768px) {
203
+ .share-inherited-short {
204
+ display: inline;
205
+ }
206
+ .share-inherited-full {
207
+ display: none;
208
+ }
209
+ }
210
+
211
+
167
212
  .share-modal-pending {
168
213
  opacity: 0.6;
169
214
  }
215
+
216
+ .share-inherited-item {
217
+ opacity: 0.8;
218
+ }
219
+
220
+ .share-pending-item {
221
+ opacity: 0.7;
222
+ }
223
+
224
+ .share-pending-badge {
225
+ display: inline-block;
226
+ font-size: var(--text-00);
227
+ color: var(--color-warning);
228
+ font-weight: var(--weight-5);
229
+ margin-left: 0.3em;
230
+ vertical-align: middle;
231
+ }
232
+
170
233
  .share-modal-spinner {
171
234
  display: inline-block;
172
235
  width: 12px;
173
236
  height: 12px;
174
- border: 2px solid var(--text-muted, #999);
237
+ border: 2px solid var(--text-muted);
175
238
  border-top-color: transparent;
176
239
  border-radius: 50%;
177
240
  animation: share-spinner 0.6s linear infinite;
@@ -223,7 +286,8 @@
223
286
  background: var(--surface-section);
224
287
  border: 1px solid var(--border-color);
225
288
  padding: 0.5em;
226
- box-shadow: 0 2px 8px var(--border-color);
289
+ box-shadow: var(--shadow-3);
290
+ border-radius: var(--radius-2);
227
291
  min-width: 220px;
228
292
  top: calc(100% + 4px);
229
293
  left: 0;
@@ -9,6 +9,9 @@ module Collavre
9
9
  @shared_list = CreativeShare.where(creative: @creative)
10
10
  .includes(user: [ avatar_attachment: :blob ])
11
11
 
12
+ # Build inherited shares from ancestor creatives
13
+ @inherited_shares = build_inherited_shares(@creative)
14
+
12
15
  @pending_invitations = Invitation.where(creative: @creative, accepted_at: nil)
13
16
  .where("expires_at > ?", Time.current)
14
17
  .order(created_at: :desc)
@@ -151,5 +154,29 @@ module Collavre
151
154
  def all_descendants(creative)
152
155
  creative.children.flat_map { |child| [ child ] + all_descendants(child) }
153
156
  end
157
+
158
+ # Returns inherited shares from ancestor creatives.
159
+ # Each entry is a hash with :share, :source_creative keys.
160
+ # Only includes the closest (most specific) share per user.
161
+ def build_inherited_shares(creative)
162
+ ancestors = creative.ancestors
163
+ return [] if ancestors.empty?
164
+
165
+ ancestor_ids = ancestors.pluck(:id)
166
+ direct_user_ids = CreativeShare.where(creative: creative).pluck(:user_id).compact
167
+
168
+ ancestor_shares = CreativeShare
169
+ .where(creative_id: ancestor_ids)
170
+ .where.not(user_id: direct_user_ids) # Exclude users who already have direct shares
171
+ .includes(:creative, user: [ avatar_attachment: :blob ])
172
+
173
+ # Group by user_id and pick the closest ancestor share
174
+ ancestor_shares.group_by(&:user_id).filter_map do |_user_id, shares|
175
+ closest = CreativeShare.closest_parent_share(ancestor_ids, shares)
176
+ next unless closest && closest.permission != "no_access"
177
+
178
+ { share: closest, source_creative: closest.creative }
179
+ end
180
+ end
154
181
  end
155
182
  end
@@ -1,4 +1,4 @@
1
- import { LitElement, html, nothing } from "lit";
1
+ import { LitElement, html, svg, nothing } from "lit";
2
2
  import DOMPurify from "dompurify";
3
3
  import { unsafeHTML } from "lit/directives/unsafe-html.js";
4
4
  import { parseEmojis } from "../utils/emoji_parser";
@@ -412,7 +412,7 @@ class CreativeTreeRow extends LitElement {
412
412
  const classes = "before-link creative-toggle-btn creative-action-btn";
413
413
  if (this.hasChildren) {
414
414
  return html`
415
- <div class=${classes} data-creative-id=${this.creativeId}>${this._toggleSymbol()}</div>
415
+ <div class=${classes} data-creative-id=${this.creativeId}>${this._toggleIcon()}</div>
416
416
  `;
417
417
  }
418
418
  return html`
@@ -424,8 +424,15 @@ class CreativeTreeRow extends LitElement {
424
424
  `;
425
425
  }
426
426
 
427
- _toggleSymbol() {
428
- return this.expanded ? "▼" : "▶";
427
+ _toggleIcon() {
428
+ if (this.expanded) {
429
+ return svg`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
430
+ <path d="M6 9L12 15L18 9"/>
431
+ </svg>`;
432
+ }
433
+ return svg`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
434
+ <path d="M9 6L15 12L9 18"/>
435
+ </svg>`;
429
436
  }
430
437
 
431
438
  _handleToggleClick(event) {
@@ -3,6 +3,9 @@ import { Controller } from "@hotwired/stimulus"
3
3
  export default class extends Controller {
4
4
  static targets = ["list", "toggleButton"]
5
5
 
6
+ static ICON_CONTEXT_LINK = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>'
7
+ static ICON_CONTEXT_PIN = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></svg>'
8
+
6
9
  connect() {
7
10
  this.contexts = []
8
11
  this.canManage = false
@@ -75,9 +78,9 @@ export default class extends Controller {
75
78
  const activeLinked = this.contexts.filter(c => !c.disabled).length
76
79
  const selfActive = this.selfContextDisabled ? 0 : 1
77
80
  const total = activeLinked + selfActive
78
- this.toggleButtonTarget.textContent = `🔗 ${total}`
81
+ this.toggleButtonTarget.innerHTML = `${this.constructor.ICON_CONTEXT_LINK} ${total}`
79
82
  } else {
80
- this.toggleButtonTarget.textContent = '🔗'
83
+ this.toggleButtonTarget.innerHTML = this.constructor.ICON_CONTEXT_LINK
81
84
  }
82
85
 
83
86
  // Auto-show if linked contexts exist, otherwise keep hidden
@@ -111,7 +114,7 @@ export default class extends Controller {
111
114
  html += `<span class="context-chip context-self ${selfClass}"
112
115
  data-action="click->comments--contexts#toggleSelfContext"
113
116
  title="${this.selfContextLabel}">
114
- 📌 ${selfLabel}
117
+ ${this.constructor.ICON_CONTEXT_PIN} ${selfLabel}
115
118
  </span>`
116
119
 
117
120
  this.contexts.forEach(ctx => {
@@ -123,7 +126,7 @@ export default class extends Controller {
123
126
  data-action="click->comments--contexts#toggleContext ${dragActions} ${reorderActions}"
124
127
  data-context-id="${ctx.id}"
125
128
  title="${ctx.inherited ? this.inheritedLabel : ''}">
126
- 🔗 ${this._escapeHtml(ctx.description)}`
129
+ ${this.constructor.ICON_CONTEXT_LINK} ${this._escapeHtml(ctx.description)}`
127
130
 
128
131
  html += `<button class="navigate-context-btn" data-action="click->comments--contexts#navigateToContext" data-context-id="${ctx.id}" title="${this.navigateLabel}">\u2192</button>`
129
132
 
@@ -387,6 +387,12 @@ export default class extends Controller {
387
387
 
388
388
  handleTouchStart(event) {
389
389
  if (!this.isMobile()) return
390
+ // Ignore swipe when share modal is open
391
+ const shareModal = document.getElementById("share-creative-modal")
392
+ if (shareModal && shareModal.style.display === "flex") {
393
+ this.touchStartY = null
394
+ return
395
+ }
390
396
  if (!event.target.closest('#comments-list')) {
391
397
  this.touchStartY = event.touches[0].clientY
392
398
  } else {
@@ -1,6 +1,9 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
  import { createSubscription } from "../../services/cable"
3
3
 
4
+ const ICON_ARCHIVE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>`
5
+ const ICON_RESTORE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6.69 3L3 13"/></svg>`
6
+
4
7
  export default class extends Controller {
5
8
  static targets = ["list"]
6
9
 
@@ -117,7 +120,7 @@ export default class extends Controller {
117
120
  #${topic.name}`
118
121
 
119
122
  if (canManage) {
120
- html += `<button class="archive-topic-btn" data-action="click->comments--topics#archiveTopic" data-id="${topic.id}" title="📦">📦</button>`
123
+ html += `<button class="archive-topic-btn" data-action="click->comments--topics#archiveTopic" data-id="${topic.id}" title="Archive">${ICON_ARCHIVE}</button>`
121
124
  html += `<button class="delete-topic-btn" data-action="click->comments--topics#deleteTopic" data-id="${topic.id}">&times;</button>`
122
125
  }
123
126
 
@@ -127,13 +130,13 @@ export default class extends Controller {
127
130
  // Archived topics section
128
131
  if (this.archivedTopics && this.archivedTopics.length > 0) {
129
132
  html += `<span class="topic-archived-toggle" data-action="click->comments--topics#toggleArchivedTopics">
130
- 📦 ${this.archivedTopics.length}
133
+ ${ICON_ARCHIVE} ${this.archivedTopics.length}
131
134
  </span>`
132
135
  if (this.showingArchived) {
133
136
  this.archivedTopics.forEach(topic => {
134
137
  html += `<span class="topic-tag topic-archived" data-id="${topic.id}">
135
138
  #${topic.name}
136
- ${canManage ? `<button class="unarchive-topic-btn" data-action="click->comments--topics#unarchiveTopic" data-id="${topic.id}" title="Restore">↩</button>` : ''}
139
+ ${canManage ? `<button class="unarchive-topic-btn" data-action="click->comments--topics#unarchiveTopic" data-id="${topic.id}" title="Restore">${ICON_RESTORE}</button>` : ''}
137
140
  </span>`
138
141
  })
139
142
  }
@@ -316,7 +319,7 @@ export default class extends Controller {
316
319
  const confirmText = this.listTarget.dataset.confirmDeleteText || "This will delete all messages in this topic. Are you sure?"
317
320
  if (!confirm(confirmText)) return
318
321
 
319
- const topicId = event.target.dataset.id
322
+ const topicId = event.currentTarget.dataset.id
320
323
  if (!topicId) return
321
324
 
322
325
  try {
@@ -343,7 +346,7 @@ export default class extends Controller {
343
346
 
344
347
  async archiveTopic(event) {
345
348
  event.stopPropagation()
346
- const topicId = event.target.dataset.id
349
+ const topicId = event.currentTarget.dataset.id
347
350
  if (!topicId) return
348
351
 
349
352
  try {
@@ -370,7 +373,7 @@ export default class extends Controller {
370
373
 
371
374
  async unarchiveTopic(event) {
372
375
  event.stopPropagation()
373
- const topicId = event.target.dataset.id
376
+ const topicId = event.currentTarget.dataset.id
374
377
  if (!topicId) return
375
378
 
376
379
  try {
@@ -78,10 +78,10 @@ export default class extends Controller {
78
78
  updateExpandButton() {
79
79
  if (!this.hasExpandTarget) return
80
80
  const button = this.expandTarget
81
- const icon = button.querySelector('span')
82
- if (icon) {
83
- icon.textContent = this.allExpanded ? '' : ''
84
- }
81
+ const expandIcon = button.querySelector('.icon-expand')
82
+ const collapseIcon = button.querySelector('.icon-collapse')
83
+ if (expandIcon) expandIcon.style.display = this.allExpanded ? 'none' : ''
84
+ if (collapseIcon) collapseIcon.style.display = this.allExpanded ? '' : 'none'
85
85
  const expandText = button.dataset.expandText
86
86
  const collapseText = button.dataset.collapseText
87
87
  button.ariaLabel = this.allExpanded ? collapseText : expandText
@@ -55,7 +55,9 @@ export default class extends Controller {
55
55
  // Update mobile button text
56
56
  if (mobileBtn) {
57
57
  const label = this._showingArchived ? (mobileBtn.dataset.hideText || '') : (mobileBtn.dataset.showText || '')
58
- mobileBtn.textContent = '📦 ' + label
58
+ const iconSpan = mobileBtn.querySelector('span')
59
+ const iconHtml = iconSpan ? iconSpan.outerHTML + ' ' : ''
60
+ mobileBtn.innerHTML = iconHtml + label
59
61
  }
60
62
 
61
63
  const url = new URL(this.urlValue, window.location.origin)
@@ -99,6 +99,9 @@ export default class extends Controller {
99
99
  modal.style.display = "flex"
100
100
  document.body.classList.add("no-scroll")
101
101
 
102
+ // Constrain popup-box within viewport
103
+ this.#constrainModalHeight(modal)
104
+
102
105
  const closeBtn = document.getElementById("close-share-modal")
103
106
  if (closeBtn) {
104
107
  closeBtn.onclick = () => this.close()
@@ -108,11 +111,32 @@ export default class extends Controller {
108
111
  if (e.target === modal) this.close()
109
112
  }
110
113
 
114
+ // Prevent touch events from propagating to underlying elements (e.g., chat swipe-to-close)
115
+ modal.addEventListener("touchstart", (e) => e.stopPropagation(), { passive: true })
116
+ modal.addEventListener("touchmove", (e) => e.stopPropagation(), { passive: true })
117
+ modal.addEventListener("touchend", (e) => e.stopPropagation(), { passive: true })
118
+
111
119
  this.#initializeForm()
120
+ this.#initializePermissionSelects()
112
121
  this.#initializeDeleteButtons()
113
122
  this.#initializeInviteLink()
114
123
  }
115
124
 
125
+ #constrainModalHeight(modal) {
126
+ const popupBox = modal.querySelector(".popup-box")
127
+ if (!popupBox) return
128
+
129
+ // Ensure the overlay is properly styled for centering
130
+ modal.style.display = "flex"
131
+ modal.style.alignItems = "center"
132
+ modal.style.justifyContent = "center"
133
+
134
+ // Constrain popup-box height to viewport
135
+ const maxH = window.innerHeight - 32 // 16px margin top + bottom
136
+ popupBox.style.maxHeight = `${maxH}px`
137
+ popupBox.style.overflowY = "auto"
138
+ }
139
+
116
140
  #initializeForm() {
117
141
  const form = document.getElementById("share-creative-form")
118
142
  if (!form) return
@@ -211,6 +235,39 @@ export default class extends Controller {
211
235
  return li
212
236
  }
213
237
 
238
+ #initializePermissionSelects() {
239
+ const modal = document.getElementById("share-creative-modal")
240
+ if (!modal) return
241
+
242
+ const selects = modal.querySelectorAll(".share-modal-permission-select:not([disabled])")
243
+ selects.forEach(select => {
244
+ select.addEventListener("change", () => {
245
+ const url = select.dataset.updateUrl
246
+ const permission = select.value
247
+ const originalClass = select.className
248
+
249
+ // Update visual class immediately
250
+ select.className = select.className.replace(/org-chart-permission-\w+/g, "")
251
+ select.classList.add("org-chart-permission-select", "share-modal-permission-select", `org-chart-permission-${permission}`)
252
+
253
+ fetch(url, {
254
+ method: "PATCH",
255
+ headers: {
256
+ "Content-Type": "application/json",
257
+ "X-CSRF-Token": document.querySelector("meta[name='csrf-token']")?.content,
258
+ "Accept": "application/json"
259
+ },
260
+ body: JSON.stringify({ permission })
261
+ }).then(response => {
262
+ if (!response.ok) throw new Error("Failed")
263
+ }).catch(() => {
264
+ select.className = originalClass
265
+ this.#showMessage(this.#errorFallbackMessage, "error")
266
+ })
267
+ })
268
+ })
269
+ }
270
+
214
271
  #initializeDeleteButtons() {
215
272
  const modal = document.getElementById("share-creative-modal")
216
273
  if (!modal) return
@@ -13,7 +13,7 @@ module Collavre
13
13
  @reply_comment = reply_comment
14
14
  end
15
15
 
16
- def handle(error)
16
+ def handle(error, summary: nil)
17
17
  cleanup_placeholder
18
18
 
19
19
  broadcast_idle
@@ -22,7 +22,7 @@ module Collavre
22
22
 
23
23
  log_action(error)
24
24
 
25
- create_approval_comment(error)
25
+ create_approval_comment(error, summary: summary)
26
26
  end
27
27
 
28
28
  private
@@ -64,7 +64,7 @@ module Collavre
64
64
  )
65
65
  end
66
66
 
67
- def create_approval_comment(error)
67
+ def create_approval_comment(error, summary: nil)
68
68
  return unless @creative
69
69
 
70
70
  approver = @creative.user || User.find_by(id: @context.dig("comment", "user_id"))
@@ -92,6 +92,10 @@ module Collavre
92
92
  arguments: args_display
93
93
  )
94
94
 
95
+ if summary.present?
96
+ content += "\n#{I18n.t('collavre.ai_agent.approval.summary_header')}\n#{summary}\n"
97
+ end
98
+
95
99
  original_comment = Comment.find_by(id: @context.dig("comment", "id"))
96
100
  topic_id = original_comment&.topic_id
97
101
 
@@ -48,8 +48,8 @@ module Collavre
48
48
  @lifecycle_manager.broadcast_status("thinking")
49
49
 
50
50
  # Execute AI chat with streaming
51
- client = build_ai_client(system_prompt)
52
- stream_response(client, messages)
51
+ @client = build_ai_client(system_prompt)
52
+ stream_response(@client, messages)
53
53
 
54
54
  log_action("completion", { response: @streamer.content })
55
55
 
@@ -62,10 +62,11 @@ module Collavre
62
62
  @streamer.content
63
63
  end
64
64
  rescue ApprovalPendingError => e
65
+ summary = generate_approval_summary(e)
65
66
  AiAgent::ApprovalHandler.new(
66
67
  task: @task, agent: @agent, context: @context,
67
68
  creative: @creative, reply_comment: @reply_comment
68
- ).handle(e)
69
+ ).handle(e, summary: summary)
69
70
  raise
70
71
  rescue CancelledError
71
72
  handle_cancelled
@@ -194,6 +195,18 @@ module Collavre
194
195
  )
195
196
  end
196
197
 
198
+ def generate_approval_summary(error)
199
+ return nil unless @client
200
+
201
+ prompt = I18n.t(
202
+ "collavre.ai_agent.approval.summary_prompt",
203
+ tool_name: error.tool_name,
204
+ arguments: error.tool_arguments.present? ? JSON.pretty_generate(error.tool_arguments) : "(none)"
205
+ )
206
+
207
+ @client.ask(prompt)
208
+ end
209
+
197
210
  def build_agent_context(creative)
198
211
  return {} unless creative
199
212
 
@@ -57,10 +57,10 @@ module Collavre
57
57
  Rails.logger.warn "Unsupported LLM vendor '#{@vendor}'. Attempting to use default (google)."
58
58
  end
59
59
 
60
- conversation = build_conversation(tools)
61
- add_messages(conversation, contents)
60
+ @conversation = build_conversation(tools)
61
+ add_messages(@conversation, contents)
62
62
 
63
- response = conversation.complete do |chunk|
63
+ response = @conversation.complete do |chunk|
64
64
  delta = extract_chunk_content(chunk)
65
65
  next if delta.blank?
66
66
 
@@ -83,7 +83,8 @@ module Collavre
83
83
 
84
84
  response_content.presence
85
85
  rescue ApprovalPendingError
86
- raise # Re-raise approval errors without catching them
86
+ # Preserve conversation for follow-up (e.g. generating approval summary)
87
+ raise
87
88
  rescue CancelledError
88
89
  raise # Re-raise cancellation errors without catching them
89
90
  rescue StandardError => e
@@ -97,8 +98,8 @@ module Collavre
97
98
  @last_input_tokens = input_tokens || 0
98
99
  @last_output_tokens = output_tokens || 0
99
100
  log_interaction(
100
- messages: conversation.messages.to_a || Array(contents),
101
- tools: conversation.tools.to_a,
101
+ messages: @conversation&.messages&.to_a || Array(contents),
102
+ tools: @conversation&.tools&.to_a || [],
102
103
  response_content: response_content.presence,
103
104
  error_message: error_message,
104
105
  input_tokens: input_tokens,
@@ -106,6 +107,21 @@ module Collavre
106
107
  )
107
108
  end
108
109
 
110
+ # Ask a follow-up question using the existing conversation context.
111
+ # Used to generate approval summaries with full conversation history.
112
+ # Returns the response content string, or nil on failure.
113
+ def ask(prompt)
114
+ return nil unless @conversation
115
+
116
+ # Disable tool calls for summary generation to avoid recursive approval
117
+ @conversation.with_tools(replace: true)
118
+ response = @conversation.ask(prompt)
119
+ response&.content&.strip.presence
120
+ rescue StandardError => e
121
+ Rails.logger.warn("AiClient#ask failed: #{e.class} #{e.message}")
122
+ nil
123
+ end
124
+
109
125
  private
110
126
 
111
127
  attr_reader :vendor, :model, :system_prompt, :llm_api_key, :context
@@ -25,8 +25,8 @@ module Tools
25
25
  query: T.nilable(String),
26
26
  level: T.nilable(Integer),
27
27
  tags: T.nilable(String),
28
- progress_min: T.nilable(Float),
29
- progress_max: T.nilable(Float),
28
+ progress_min: T.nilable(Numeric),
29
+ progress_max: T.nilable(Numeric),
30
30
  updated_since: T.nilable(String),
31
31
  include_comments: T.nilable(T::Boolean),
32
32
  format: T.nilable(String)
@@ -15,7 +15,7 @@ module Tools
15
15
  tool_param :progress, description: "Set to 1.0 to mark a leaf Creative as complete. Only 1.0 is allowed; partial progress and updates on parent Creatives are rejected.", required: false
16
16
  tool_param :parent_id, description: "New parent Creative ID to move this Creative under. Use null/0 to make it a root Creative.", required: false
17
17
 
18
- sig { params(id: Integer, description: T.nilable(String), progress: T.nilable(Float), parent_id: T.nilable(Integer)).returns(T::Hash[Symbol, T.untyped]) }
18
+ sig { params(id: Integer, description: T.nilable(String), progress: T.nilable(Numeric), parent_id: T.nilable(Integer)).returns(T::Hash[Symbol, T.untyped]) }
19
19
  def call(id:, description: nil, progress: nil, parent_id: nil)
20
20
  raise "Current.user is required" unless Current.user
21
21
 
@@ -60,7 +60,7 @@
60
60
  data-action="click->comments--contexts#toggleVisibility"
61
61
  type="button"
62
62
  title="<%= t('collavre.contexts.toggle_label', default: 'Contexts') %>"
63
- style="display:none;">🔗</button>
63
+ style="display:none;"><%= svg_tag "context-link.svg", class: "comments-popup-action-icon", width: 16, height: 16 %></button>
64
64
  <button class="comments-popup-action comments-popup-fullscreen"
65
65
  data-comments--popup-target="fullscreenButton"
66
66
  data-action="click->comments--popup#toggleFullscreen"
@@ -26,7 +26,7 @@
26
26
  <%= button_to t('collavre.creatives.index.slide_view'), slide_view_creative_path(@parent_creative), method: :get, class: 'popup-menu-item' %>
27
27
  <% end %>
28
28
  <% if authenticated? %>
29
- <button type="button" id="toggle-archived-btn-mobile" class="popup-menu-item" data-show-text="<%= t('collavre.creatives.index.show_archived') %>" data-hide-text="<%= t('collavre.creatives.index.hide_archived') %>">📦 <%= t('collavre.creatives.index.show_archived') %></button>
29
+ <button type="button" id="toggle-archived-btn-mobile" class="popup-menu-item" data-show-text="<%= t('collavre.creatives.index.show_archived') %>" data-hide-text="<%= t('collavre.creatives.index.hide_archived') %>"><span aria-hidden="true"><%= svg_tag 'archive.svg', width: 16, height: 16 %></span> <%= t('collavre.creatives.index.show_archived') %></button>
30
30
  <% end %>
31
31
  <% if authenticated? && (!params[:id] || (current_creative && current_creative.has_permission?(Current.user, :write))) %>
32
32
  <button type="button" class="popup-menu-item" data-controller="click-target" data-click-target-id-value="import-markdown-btn" data-action="click->click-target#trigger"><%= t('collavre.creatives.index.import_markdown') %></button>