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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/collavre/actiontext.css +3 -3
- data/app/assets/stylesheets/collavre/comments_popup.css +44 -31
- data/app/assets/stylesheets/collavre/creatives.css +25 -16
- data/app/assets/stylesheets/collavre/dark_mode.css +56 -3
- data/app/assets/stylesheets/collavre/design_tokens.css +10 -2
- data/app/assets/stylesheets/collavre/mention_menu.css +2 -2
- data/app/assets/stylesheets/collavre/popup.css +82 -18
- data/app/controllers/collavre/creative_shares_controller.rb +27 -0
- data/app/javascript/components/creative_tree_row.js +11 -4
- data/app/javascript/controllers/comments/contexts_controller.js +7 -4
- data/app/javascript/controllers/comments/popup_controller.js +6 -0
- data/app/javascript/controllers/comments/topics_controller.js +9 -6
- data/app/javascript/controllers/creatives/expansion_controller.js +4 -4
- data/app/javascript/controllers/creatives/tree_controller.js +3 -1
- data/app/javascript/controllers/share_modal_controller.js +57 -0
- data/app/services/collavre/ai_agent/approval_handler.rb +7 -3
- data/app/services/collavre/ai_agent_service.rb +16 -3
- data/app/services/collavre/ai_client.rb +22 -6
- data/app/services/collavre/tools/creative_retrieval_service.rb +2 -2
- data/app/services/collavre/tools/creative_update_service.rb +1 -1
- data/app/views/collavre/comments/_comments_popup.html.erb +1 -1
- data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +1 -1
- data/app/views/collavre/creatives/_share_modal.html.erb +71 -28
- data/app/views/collavre/creatives/index.html.erb +2 -2
- data/app/views/collavre/shared/_custom_theme_style.html.erb +21 -0
- data/app/views/collavre/users/new_ai.html.erb +1 -1
- data/app/views/layouts/collavre/slide.html.erb +1 -17
- data/config/locales/ai_agent.en.yml +10 -0
- data/config/locales/ai_agent.ko.yml +10 -0
- data/config/locales/creatives.en.yml +3 -1
- data/config/locales/creatives.ko.yml +3 -1
- data/lib/collavre/version.rb +1 -1
- 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:
|
|
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:
|
|
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
|
|
27
|
-
background: var(--surface-section
|
|
28
|
-
border: 1px solid var(--border-color
|
|
29
|
-
box-shadow: var(--shadow-2
|
|
30
|
-
border-radius:
|
|
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(--
|
|
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:
|
|
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
|
|
155
|
-
color: var(--success
|
|
156
|
-
border: 1px solid var(--success
|
|
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
|
|
160
|
-
color: var(--danger
|
|
161
|
-
border: 1px solid var(--danger
|
|
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
|
|
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:
|
|
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.
|
|
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
|
-
|
|
428
|
-
|
|
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.
|
|
81
|
+
this.toggleButtonTarget.innerHTML = `${this.constructor.ICON_CONTEXT_LINK} ${total}`
|
|
79
82
|
} else {
|
|
80
|
-
this.toggleButtonTarget.
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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}">×</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
|
-
|
|
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"
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
101
|
-
tools: conversation
|
|
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(
|
|
29
|
-
progress_max: T.nilable(
|
|
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(
|
|
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;"
|
|
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') %>"
|
|
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>
|