1import { Controller } from "@hotwired/stimulus" 2 3export default class extends Controller { 4 static targets = [ 5 "scrollContainer", "messages", "form", "input", "submit", 6 "cannedPrompts", "cannedPromptId", "customerName", 7 "customerPicker", "customerSearch", "customerList", 8 "selectedCustomer", "selectedCustomerName" 9 ] 10 11 static values = { 12 territoryCustomers: { type: Array, default: [] } 13 } 14 15 connect() { 16 this.scrollToBottom() 17 this.observer = new MutationObserver(() => this.scrollToBottom()) 18 this.observer.observe(this.messagesTarget, { childList: true, subtree: true }) 19 this.pendingPromptId = null 20 this.activeSubmission = null 21 } 22 23 disconnect() { 24 this.observer?.disconnect() 25 this.activeSubmission?.abort() 26 } 27 28 handleKeydown(event) { 29 if (event.key !== "Enter" || event.shiftKey) return 30 31 event.preventDefault() 32 33 if (this.inputTarget.disabled || this.inputTarget.value.trim() === "") return 34 35 this.formTarget.requestSubmit() 36 } 37 38 selectCannedPrompt(event) { 39 const button = event.currentTarget 40 const promptId = button.dataset.cannedPromptId 41 const requiresCustomer = button.dataset.requiresCustomer === "true" 42 43 if (requiresCustomer) { 44 this.pendingPromptId = promptId 45 this.inputTarget.value = this.findPromptLabel(promptId) 46 this.showCustomerPicker() 47 } else { 48 this.submitCannedPrompt(promptId) 49 } 50 } 51 52 showCustomerPicker() { 53 if (!this.hasCustomerPickerTarget) return 54 55 this.customerPickerTarget.classList.remove("hidden") 56 this.customerSearchTarget.value = "" 57 this.customerSearchTarget.focus() 58 this.renderCustomerList(this.territoryCustomersValue) 59 } 60 61 hideCustomerPicker() { 62 if (!this.hasCustomerPickerTarget) return 63 64 this.customerPickerTarget.classList.add("hidden") 65 this.customerSearchTarget.value = "" 66 this.pendingPromptId = null 67 this.inputTarget.value = "" 68 } 69 70 filterCustomers() { 71 const query = this.customerSearchTarget.value.toLowerCase().trim() 72 if (query === "") { 73 this.renderCustomerList(this.territoryCustomersValue) 74 return 75 } 76 77 const filtered = this.territoryCustomersValue.filter(c => 78 c.name.toLowerCase().includes(query) || c.code.toLowerCase().includes(query) 79 ) 80 this.renderCustomerList(filtered) 81 } 82 83 handleCustomerKeydown(event) { 84 if (event.key === "Escape") { 85 this.hideCustomerPicker() 86 } 87 } 88 89 renderCustomerList(customers) { 90 const list = this.customerListTarget 91 const shown = customers.slice(0, 50) 92 93 list.innerHTML = shown.map(c => 94 `<li class="cursor-pointer px-3 py-2 text-sm text-sc-text hover:bg-sc-raised transition-colors" 95 data-action="click->rep-assistant--chat#pickCustomer" 96 data-customer-name="${this.escapeAttr(c.name)}">${this.escapeHtml(c.name)}<span class="ml-2 text-xs text-sc-muted">${this.escapeHtml(c.code)}</span></li>` 97 ).join("") 98 99 if (customers.length > 50) { 100 list.innerHTML += `<li class="px-3 py-2 text-xs text-sc-muted italic">Showing 50 of ${customers.length} â type to narrow results</li>` 101 } 102 103 if (customers.length === 0) { 104 list.innerHTML = `<li class="px-3 py-2 text-sm text-sc-muted italic">No customers found</li>` 105 } 106 } 107 108 pickCustomer(event) { 109 const customerName = event.currentTarget.dataset.customerName 110 const promptId = this.pendingPromptId 111 if (!promptId) return 112 113 this.selectedCustomerValue = customerName 114 this.customerNameTarget.value = customerName 115 this.hideCustomerPicker() 116 117 if (this.hasSelectedCustomerTarget) { 118 this.selectedCustomerNameTarget.textContent = customerName 119 this.selectedCustomerTarget.classList.remove("hidden") 120 } 121 122 this.submitCannedPrompt(promptId, customerName) 123 } 124 125 clearCustomer() { 126 this.selectedCustomerValue = null 127 this.customerNameTarget.value = "" 128 this.pendingPromptId = null 129 130 if (this.hasSelectedCustomerTarget) { 131 this.selectedCustomerTarget.classList.add("hidden") 132 } 133 } 134 135 submitCannedPrompt(promptId, customerName = null) { 136 this.cannedPromptIdTarget.value = promptId 137 this.customerNameTarget.value = customerName || "" 138 this.inputTarget.value = "" 139 this.inputTarget.removeAttribute("required") 140 141 this.hideCannedPrompts() 142 143 const label = this.findPromptLabel(promptId, customerName) 144 this.appendUserMessage(label) 145 this.appendLoadingIndicator() 146 this.disableComposer() 147 148 this.formTarget.requestSubmit() 149 } 150 151 findPromptLabel(promptId, customerName) { 152 const button = this.element.querySelector(`[data-canned-prompt-id="${promptId}"]`) 153 let label = button ? button.textContent.trim() : promptId 154 if (customerName) { 155 label = label.replace(/a customer/, customerName) 156 } 157 return label 158 } 159 160 hideCannedPrompts() { 161 if (this.hasCannedPromptsTarget) { 162 this.cannedPromptsTarget.remove() 163 } 164 } 165 166 async submitWithStream(event) { 167 event.preventDefault() 168 169 const isCanned = this.cannedPromptIdTarget.value !== "" 170 if (!isCanned) { 171 const content = this.inputTarget.value.trim() 172 if (!content) return 173 174 this.appendUserMessage(content) 175 this.appendLoadingIndicator() 176 } 177 178 const formData = new FormData(this.formTarget) 179 180 this.disableComposer() 181 this.clearInput() 182 183 this.activeSubmission?.abort() 184 this.activeSubmission = new AbortController() 185 186 const url = this.formTarget.action 187 if (!url) { 188 this.showStreamError("Something went wrong. Please try again.") 189 this.finishSubmission() 190 return 191 } 192 193 const headers = { Accept: "text/vnd.turbo-stream.html" } 194 const csrfToken = this.csrfToken() 195 if (csrfToken) headers["X-CSRF-Token"] = csrfToken 196 197 try { 198 const response = await fetch(url, { 199 method: "POST", 200 body: formData, 201 headers, 202 signal: this.activeSubmission.signal, 203 credentials: "same-origin" 204 }) 205 206 if (!response.ok) { 207 throw new Error(`Request failed (${response.status})`) 208 } 209 210 await this.consumeTurboStream(response) 211 } catch (error) { 212 if (error.name !== "AbortError") { 213 console.error("Rep Assistant stream error:", error) 214 this.showStreamError("Something went wrong. Please try again.") 215 } 216 } finally { 217 this.activeSubmission = null 218 this.finishSubmission() 219 } 220 } 221 222 async consumeTurboStream(response) {
223 const turbo = this.turbo() 224 if (!turbo?.renderStreamMessage) { 225 throw new Error("Turbo stream rendering is unavailable") 226 } 227 228 const reader = response.body?.getReader() 229 if (!reader) { 230 const html = await response.text() 231 if (html.trim()) turbo.renderStreamMessage(html) 232 return 233 } 234 235 const decoder = new TextDecoder() 236 let buffer = "" 237 238 while (true) { 239 const { done, value } = await reader.read() 240 if (done) break 241 242 buffer += decoder.decode(value, { stream: true }) 243 buffer = this.renderBufferedTurboStreams(buffer, turbo) 244 } 245 246 buffer += decoder.decode() 247 if (buffer.trim()) { 248 this.renderBufferedTurboStreams(buffer, turbo, { flush: true }) 249 } 250 } 251 252 renderBufferedTurboStreams(buffer, turbo, { flush = false } = {}) { 253 const pattern = /<turbo-stream[\s\S]*?<\/turbo-stream>/g 254 let match 255 let lastIndex = 0 256 257 while ((match = pattern.exec(buffer)) !== null) { 258 turbo.renderStreamMessage(match[0]) 259 lastIndex = pattern.lastIndex 260 } 261 262 const remainder = buffer.slice(lastIndex) 263 if (flush && remainder.trim()) { 264 turbo.renderStreamMessage(remainder) 265 return "" 266 } 267 268 return remainder 269 } 270 271 showStreamError(message) { 272 document.getElementById("rep-assistant-loading")?.remove() 273 document.getElementById("rep-assistant-pending-user")?.remove() 274 275 this.messagesTarget.insertAdjacentHTML( 276 "beforeend", 277 `<div class="flex justify-start"> 278 <div class="max-w-[min(85%,720px)] rounded-2xl rounded-bl-md border border-sc-crimson/30 bg-sc-crimson/5 px-4 py-3 text-sm leading-relaxed text-sc-crimson"> 279 ${this.escapeHtml(message)} 280 </div> 281 </div>` 282 ) 283 this.scrollToBottom() 284 } 285 286 finishSubmission() { 287 this.enableComposer() 288 this.cannedPromptIdTarget.value = "" 289 this.customerNameTarget.value = "" 290 this.selectedCustomerValue = null 291 this.inputTarget.setAttribute("required", "") 292 293 if (this.hasSelectedCustomerTarget) { 294 this.selectedCustomerTarget.classList.add("hidden") 295 } 296 297 this.scrollToBottom() 298 } 299 300 disableComposer() { 301 this.inputTarget.disabled = true 302 this.submitTarget.disabled = true 303 } 304 305 enableComposer() { 306 this.inputTarget.disabled = false 307 this.submitTarget.disabled = false 308 this.inputTarget.focus() 309 } 310 311 clearInput() { 312 this.inputTarget.value = "" 313 } 314 315 appendUserMessage(content) { 316 if (document.getElementById("rep-assistant-pending-user")) return 317 318 const formatted = this.escapeHtml(content).replace(/\n/g, "<br />") 319 320 this.messagesTarget.insertAdjacentHTML( 321 "beforeend", 322 `<div id="rep-assistant-pending-user" class="flex justify-end"> 323 <div class="max-w-[min(85%,720px)] rounded-2xl rounded-br-md bg-sc-crimson px-4 py-3 text-sm leading-relaxed text-white"> 324 <p class="mb-0 text-white">${formatted}</p> 325 </div> 326 </div>` 327 ) 328 this.scrollToBottom() 329 } 330 331 appendLoadingIndicator() { 332 if (document.getElementById("rep-assistant-loading")) return 333 334 this.messagesTarget.insertAdjacentHTML( 335 "beforeend", 336 `<div id="rep-assistant-loading" class="flex justify-start"> 337 <div class="max-w-[min(85%,720px)] animate-pulse rounded-2xl rounded-bl-md border border-sc-border bg-white px-4 py-3 text-sm text-sc-text-3"> 338 Thinking... 339 </div> 340 </div>` 341 ) 342 this.scrollToBottom() 343 } 344 345 scrollToBottom() { 346 const container = this.hasScrollContainerTarget ? this.scrollContainerTarget : this.messagesTarget 347 container.scrollTop = container.scrollHeight 348 } 349 350 csrfToken() { 351 return document.querySelector('meta[name="csrf-token"]')?.content 352 } 353 354 turbo() { 355 return window.Turbo || (typeof Turbo !== "undefined" ? Turbo : null) 356 } 357 358 escapeHtml(text) { 359 const div = document.createElement("div") 360 div.textContent = text 361 return div.innerHTML 362 } 363 364 escapeAttr(text) { 365 return text.replace(/"/g, """).replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">") 366 } 367} 368;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.