// ==UserScript== // @name NinjaCat Chat UX Enhancements // @namespace http://tampermonkey.net/ // @version 1.7.0 // @description Multi-file drag-drop, message queue, auto-linkify URLs, and partial response preservation for NinjaCat chat // @author NinjaCat Tweaks // @match https://app.ninjacat.io/* // @match https://app.mymarketingreports.com/* // @grant none // @run-at document-end // @homepage https://github.com/jms830/ninjacat-tweaks // @updateURL https://raw.githubusercontent.com/jms830/ninjacat-tweaks/main/userscripts/ninjacat-chat-ux.meta.js // @downloadURL https://raw.githubusercontent.com/jms830/ninjacat-tweaks/main/userscripts/ninjacat-chat-ux.user.js // ==/UserScript== (function() { 'use strict'; // Run on chat pages AND agent builder pages const path = window.location.pathname; if (!path.includes('/chat/') && !path.includes('/agents/')) { return; } console.log('[NinjaCat Chat UX] Script loaded v1.7.0'); // ---- Configuration ---- const CONFIG = { MAX_QUEUE_SIZE: 3, ACCEPTED_FILE_TYPES: ['.csv', '.png', '.jpg', '.jpeg', '.pdf', '.txt', '.md', '.json'], DEBUG: localStorage.getItem('ninjacat-chat-debug') === 'true' }; // ---- State ---- let messageQueue = []; let isAgentProcessing = false; let queuePaused = false; let dropZoneVisible = false; let observer = null; let activeDropTarget = null; // Which file input area we're targeting let linkifyDebounceTimer = null; // Debounce timer for URL linkification let observerThrottleTimer = null; // Throttle timer for MutationObserver let pendingObserverCallback = false; // Flag to batch observer callbacks // ---- Cached DOM References ---- // These are cached to avoid repeated querySelectorAll calls let cachedTextarea = null; let cachedInputContainer = null; let cachedChatContainer = null; let cacheValidUntil = 0; // Timestamp when cache expires const CACHE_TTL = 2000; // Cache DOM refs for 2 seconds // ---- Debug Logging ---- function debugLog(...args) { if (CONFIG.DEBUG) { console.log('[NinjaCat Chat UX DEBUG]', ...args); } } // ---- App / Store Helpers ---- function getAppContext() { const app = document.querySelector('#assistants-ui')?.__vue_app__; const pinia = app?._context?.provides?.pinia || app?.config?.globalProperties?.$pinia; return { app, pinia }; } function getPiniaStores() { const { pinia } = getAppContext(); if (!pinia) return {}; const storeAccessor = pinia._s?.get ? (name) => pinia._s.get(name) : () => null; return { pinia, conversationStore: storeAccessor('conversation') || pinia.state?.value?.conversation, liveChatStore: storeAccessor('live-chat') || storeAccessor('liveChat') || pinia.state?.value?.['live-chat'] }; } function getCurrentConversationId() { const path = window.location.pathname; const match = path.match(/[0-9a-fA-F-]{12,}/); if (match) return match[0]; const parts = path.split('/').filter(Boolean); return parts[parts.length - 1] || parts[parts.length - 2] || ''; } function generateRequestId() { return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 8)}`; } function instrumentSocket(socket) { if (!socket || socket._ncInstrumented) return; socket._ncInstrumented = true; const origEmit = socket.emit; socket.emit = function(event, ...args) { if (CONFIG.DEBUG && typeof event === 'string' && event.includes('message')) { debugLog('socket.emit', event, args[0]); } return origEmit.apply(this, [event, ...args]); }; debugLog('Socket instrumentation attached'); } function getLiveSocket() { const { liveChatStore } = getPiniaStores(); let socket = liveChatStore?.socket; if (!socket && window.io?.sockets) { for (const candidate of Object.values(window.io.sockets)) { if (candidate?.connected) { socket = candidate; break; } } } if (socket) instrumentSocket(socket); return socket; } // ---- Error Recovery Functions ---- /** * Nuclear state reset - clear ALL blocking state to flip NinjaCat back to normal */ function nuclearStateReset() { try { const { liveChatStore, conversationStore, pinia } = getPiniaStores(); const conversationId = getCurrentConversationId(); if (!conversationId) { debugLog('No conversation ID for state reset'); return false; } let cleared = false; debugLog('=== NUCLEAR STATE RESET ==='); debugLog('Conversation ID:', conversationId); // Log current state before clearing if (liveChatStore) { debugLog('liveChatStore keys:', Object.keys(liveChatStore)); debugLog('streamingMessages:', liveChatStore.streamingMessages); } // Clear EVERYTHING in live-chat store that might be blocking if (liveChatStore) { // streamingMessages if (liveChatStore.streamingMessages?.[conversationId]) { debugLog('Clearing streamingMessages[' + conversationId + ']'); delete liveChatStore.streamingMessages[conversationId]; cleared = true; } // Clear entire streamingMessages if still blocking if (liveChatStore.streamingMessages && Object.keys(liveChatStore.streamingMessages).length > 0) { debugLog('Clearing ALL streamingMessages'); for (const key of Object.keys(liveChatStore.streamingMessages)) { delete liveChatStore.streamingMessages[key]; } cleared = true; } // pendingMessages if (liveChatStore.pendingMessages?.[conversationId]) { debugLog('Clearing pendingMessages'); delete liveChatStore.pendingMessages[conversationId]; cleared = true; } // isStreaming flag if (liveChatStore.isStreaming) { debugLog('Clearing isStreaming'); liveChatStore.isStreaming = false; cleared = true; } // isSending flag if (liveChatStore.isSending) { debugLog('Clearing isSending'); liveChatStore.isSending = false; cleared = true; } // activeStreams if (liveChatStore.activeStreams?.[conversationId]) { debugLog('Clearing activeStreams'); delete liveChatStore.activeStreams[conversationId]; cleared = true; } // error if (liveChatStore.error) { debugLog('Clearing liveChatStore.error'); liveChatStore.error = null; cleared = true; } } // Reset conversation state if (conversationStore) { debugLog('conversationStore keys:', Object.keys(conversationStore)); const conv = conversationStore.conversations?.[conversationId]; if (conv) { debugLog('Conversation state:', conv.state); // Reset state to IDLE regardless of current state if (conv.state && conv.state !== 'IDLE') { debugLog('Resetting state from', conv.state, 'to IDLE'); conv.state = 'IDLE'; cleared = true; } // Clear error if (conv.error) { debugLog('Clearing conversation.error'); conv.error = null; cleared = true; } // Clear generating flags if (conv.isGenerating) { debugLog('Clearing isGenerating'); conv.isGenerating = false; cleared = true; } if (conv.generating) { debugLog('Clearing generating'); conv.generating = false; cleared = true; } // Clear pending if (conv.pending) { debugLog('Clearing pending'); conv.pending = null; cleared = true; } } } // Remove error UI elements from DOM const errorButtons = document.querySelectorAll('button'); for (const btn of errorButtons) { const text = btn.textContent.toLowerCase(); if (text.includes('resend') || text.includes('edit last message')) { debugLog('Hiding error button:', text); btn.style.display = 'none'; } } debugLog('=== NUCLEAR RESET COMPLETE ==='); debugLog('Cleared:', cleared); return cleared; } catch (err) { debugLog('Error in nuclear state reset:', err); return false; } } // Alias for compatibility function clearStaleStreamingState() { return nuclearStateReset(); } /** * Click the native "Resend" button if visible */ function clickResendButton() { const buttons = document.querySelectorAll('button'); for (const btn of buttons) { const text = btn.textContent.toLowerCase().trim(); if (text === 'resend' || text.includes('resend')) { debugLog('Found and clicking Resend button'); btn.click(); return true; } } debugLog('Resend button not found'); return false; } /** * Click the native "Edit last message" button if visible */ function clickEditLastMessageButton() { const buttons = document.querySelectorAll('button'); for (const btn of buttons) { const text = btn.textContent.toLowerCase().trim(); if (text.includes('edit last message') || text.includes('edit message')) { debugLog('Found and clicking Edit last message button'); btn.click(); return true; } } debugLog('Edit last message button not found'); return false; } /** * Use native "Edit last message" flow but inject our new text * This is the simplest reliable approach - let NinjaCat handle the complexity */ function editLastMessageWithText(newText) { return new Promise((resolve) => { // Step 1: Click the Edit button if (!clickEditLastMessageButton()) { debugLog('Edit button not found'); resolve(false); return; } // Step 2: Wait for edit textarea to appear let attempts = 0; const maxAttempts = 20; const checkInterval = setInterval(() => { attempts++; // Look for the edit textarea - it's usually a new textarea that appears // or the existing one enters an "edit mode" const editTextarea = document.querySelector('textarea[placeholder*="Edit"], textarea.editing, [data-editing="true"] textarea'); const anyTextarea = document.querySelector('#autoselect-experience, textarea'); // Check if we're now in edit mode by looking for a "Save" or "Update" button const saveBtn = Array.from(document.querySelectorAll('button')).find(btn => { const t = btn.textContent.toLowerCase(); return t.includes('save') || t.includes('update') || t.includes('send'); }); const targetTextarea = editTextarea || anyTextarea; if (targetTextarea && saveBtn) { clearInterval(checkInterval); debugLog('Edit mode detected, injecting new text'); // Step 3: Inject our new text const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; nativeSetter.call(targetTextarea, newText); targetTextarea.dispatchEvent(new Event('input', { bubbles: true })); // Step 4: Click save/send after a brief delay setTimeout(() => { debugLog('Clicking save/send button'); saveBtn.click(); resolve(true); }, 100); return; } if (attempts >= maxAttempts) { clearInterval(checkInterval); debugLog('Edit mode did not appear after clicking Edit button'); resolve(false); } }, 100); }); } function sendViaSocket(messageText, options = {}) { const socket = getLiveSocket(); if (!socket) { debugLog('No live socket available for recovery send'); return false; } const context = getConversationContext(); if (!context || !context.conversationId || !context.assistantId) { debugLog('Missing context for socket send'); return false; } const basePayload = { request_id: generateRequestId(), conversation_id: context.conversationId, assistant_id: context.assistantId, message: messageText, inputs: [] }; const forceResend = options.mode === 'resend'; const canResend = Boolean(context.lastUserMessageId); const shouldResend = forceResend ? canResend : canResend && options.mode !== 'send-only'; const eventName = shouldResend ? 'resend-user-message' : 'send-user-message'; if (shouldResend) { basePayload.message_id = context.lastUserMessageId; } try { debugLog(`Emitting ${eventName} via socket`, basePayload); socket.emit(eventName, basePayload); return eventName; } catch (err) { debugLog(`${eventName} emit failed:`, err); return false; } } // ---- Conversation Context Helpers ---- /** * Get Pinia store data needed for error recovery * Returns { conversationId, assistantId, lastUserMessageId } or null */ function getConversationContext() { try { const { conversationStore } = getPiniaStores(); if (!conversationStore) { debugLog('Conversation store not found'); return null; } const conversationId = getCurrentConversationId(); if (!conversationId) { debugLog('Could not extract conversation ID'); return null; } const conversation = conversationStore.conversations?.[conversationId] || conversationStore.conversation; if (!conversation) { debugLog('Conversation data not found'); return null; } const assistantId = conversation.assistant_id || conversation.assistantId; let lastUserMessageId = null; const messages = conversation.messages || []; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role === 'user' || msg.type === 'user') { lastUserMessageId = msg.id || msg.message_id; break; } } debugLog('Conversation context:', { conversationId, assistantId, lastUserMessageId }); return { conversationId, assistantId, lastUserMessageId }; } catch (err) { console.error('[NinjaCat Chat UX] Error getting conversation context:', err); return null; } } /** * Check if conversation is in an error state that blocks normal sends */ function isConversationInErrorState() { try { const { conversationStore, liveChatStore } = getPiniaStores(); const conversationId = getCurrentConversationId(); if (conversationStore) { const conv = conversationStore.conversations?.[conversationId]; if (conv?.state === 'ERROR') { debugLog('Conversation is in ERROR state'); return true; } } if (liveChatStore?.streamingMessages?.[conversationId]) { debugLog('streamingMessages has stale entry'); return true; } const errorButtons = document.querySelectorAll('button'); for (const btn of errorButtons) { const text = btn.textContent.toLowerCase(); if (text.includes('resend') || text.includes('edit last message')) { debugLog('Error recovery buttons visible'); return true; } } return false; } catch (err) { return false; } } /** * Attempt error recovery using multiple strategies: * 1. Clear stale Pinia state so normal send works * 2. Click native Resend button if visible * 3. Click native Edit last message button if visible * Returns true if any recovery method succeeded */ function attemptErrorRecovery() { debugLog('Attempting error recovery...'); // Strategy 1: Clear stale state first - this often fixes the issue const stateCleared = clearStaleStreamingState(); if (stateCleared) { debugLog('Stale state cleared - normal send should work now'); return 'state_cleared'; } // Strategy 2: Click the Resend button if visible if (clickResendButton()) { return 'resend_clicked'; } // Strategy 3: Click Edit last message button if (clickEditLastMessageButton()) { return 'edit_clicked'; } debugLog('No error recovery method succeeded'); return false; } // ---- Error State Warning Banner ---- // Simple yellow warning when in error state to inform user let errorWarningVisible = false; /** * Show warning banner when in error state */ function showErrorStateWarning() { if (document.getElementById('nc-error-state-warning')) return; const inputWrapper = document.querySelector('.min-w-\\[200px\\].max-w-\\[840px\\]') || document.querySelector('[class*="input"]')?.closest('div[class*="w-"]'); if (!inputWrapper) return; const warning = document.createElement('div'); warning.id = 'nc-error-state-warning'; warning.className = 'nc-error-state-warning'; warning.innerHTML = ` Error state detected Use "Resend" or "Edit last message" buttons above to continue `; inputWrapper.parentNode.insertBefore(warning, inputWrapper); errorWarningVisible = true; debugLog('Error state warning shown'); } /** * Hide the error state warning */ function hideErrorStateWarning() { const warning = document.getElementById('nc-error-state-warning'); if (warning) { warning.remove(); errorWarningVisible = false; debugLog('Error state warning hidden'); } } /** * Update error state UI - call this when state might have changed */ function updateErrorStateUI() { const inErrorState = isConversationInErrorState(); if (inErrorState && !errorWarningVisible) { showErrorStateWarning(); } else if (!inErrorState && errorWarningVisible) { hideErrorStateWarning(); } // Also inject Edit button if missing in cancelled state injectEditButtonIfMissing(); } // ---- Inject Missing Edit Button ---- // NinjaCat shows "Continue" + "Resend" on cancel, but no "Edit last message" // This injects the missing button to match the error state UI /** * Check if we're in the cancelled state (has Resend but no Edit button) * and inject the Edit button if missing */ function injectEditButtonIfMissing() { // Look for the cancelled state container - has "didn't finish running" text const cancelledContainers = document.querySelectorAll('.flex.justify-center.gap-3'); for (const container of cancelledContainers) { // Check if this container has the Resend button but no Edit button const buttons = container.querySelectorAll('button.btn'); if (buttons.length === 0) continue; let hasResend = false; let hasEdit = false; for (const btn of buttons) { const text = btn.textContent.toLowerCase(); if (text.includes('resend')) hasResend = true; if (text.includes('edit')) hasEdit = true; } // If we have Resend but no Edit, inject the Edit button if (hasResend && !hasEdit && !container.querySelector('#nc-injected-edit-btn')) { injectEditButton(container); } } } /** * Inject the "Edit last message" button into the button container */ function injectEditButton(container) { debugLog('Injecting Edit last message button'); const editBtn = document.createElement('button'); editBtn.id = 'nc-injected-edit-btn'; editBtn.className = 'btn btn-small btn-primary'; editBtn.textContent = 'Edit last message'; editBtn.style.cssText = 'background-color: #3B82F6; color: white;'; editBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); debugLog('Injected Edit button clicked'); // Find and click the edit icon on the last user message triggerNativeEditMode(); }); // Insert at the end of the button group container.appendChild(editBtn); debugLog('Edit button injected successfully'); } /** * Trigger the native edit mode by clicking the edit icon on the last user message */ function triggerNativeEditMode() { // Find all user messages (they have the grey background and are self-end aligned) const userMessages = document.querySelectorAll('.styled-chat-message.self-end'); if (userMessages.length === 0) { debugLog('No user messages found'); showToast('Could not find message to edit', 'error'); return; } const lastUserMessage = userMessages[userMessages.length - 1]; // Find the edit icon button within this message // It's the SVG with the edit/pencil icon, inside a hover-visible div const editIconContainer = lastUserMessage.querySelector('.flex.justify-end'); if (editIconContainer) { // The edit icon is the first button/div with the SVG const editIcon = editIconContainer.querySelector('.cursor-pointer, [class*="cursor-pointer"]'); if (editIcon) { debugLog('Found edit icon, clicking'); editIcon.click(); return; } } // Fallback: Try finding by SVG path (the edit pencil icon) const editSvgs = lastUserMessage.querySelectorAll('svg'); for (const svg of editSvgs) { const path = svg.querySelector('path'); if (path && path.getAttribute('d')?.includes('14.1131')) { // This is the edit icon based on its path data const clickTarget = svg.closest('.cursor-pointer') || svg.parentElement; if (clickTarget) { debugLog('Found edit icon via SVG path, clicking'); clickTarget.click(); return; } } } debugLog('Could not find edit icon'); showToast('Could not find edit button', 'error'); } // ---- DOM Selectors ---- const SELECTORS = { chatTextarea: '#autoselect-experience', fileInput: 'input[type="file"].hidden', inputContainer: '.border.rounded-3xl.bg-white', inputWrapper: '.min-w-\\[200px\\].max-w-\\[840px\\]', attachIcon: '.flex.items-center > svg:first-of-type', sendButton: '.rounded-full.bg-blue-5', messagesContainer: '.conversationMessagesContainer, [class*="conversation"], [class*="messages"]', // Agent Builder specific knowledgeTab: '[data-automation-id="Knowledge"][aria-selected="true"]', knowledgeFilesSection: 'h3:contains("Files"), h3', addFileButton: '.text-blue-100:contains("Add File"), .cursor-pointer:has(.text-blue-100)' }; // File upload contexts - helps determine which input to use const FILE_CONTEXTS = { CHAT: 'chat', // Main chat or test chat BUILDER: 'builder', // Builder chat (left pane) KNOWLEDGE: 'knowledge' // Knowledge tab file uploads }; // ---- Utility Functions ---- function $(selector) { return document.querySelector(selector); } function $$(selector) { return document.querySelectorAll(selector); } /** * Invalidate cached DOM references * Call this on SPA navigation or when elements may have changed */ function invalidateCache() { cachedTextarea = null; cachedInputContainer = null; cachedChatContainer = null; cacheValidUntil = 0; debugLog('DOM cache invalidated'); } /** * Check if cache is still valid */ function isCacheValid() { return Date.now() < cacheValidUntil; } /** * Refresh cache timestamp */ function refreshCache() { cacheValidUntil = Date.now() + CACHE_TTL; } function getTextarea() { if (isCacheValid() && cachedTextarea && cachedTextarea.isConnected) { return cachedTextarea; } cachedTextarea = $(SELECTORS.chatTextarea); if (cachedTextarea) refreshCache(); return cachedTextarea; } function getFileInput() { return $(SELECTORS.fileInput); } /** * Find all file inputs on the page and return them with context */ function getAllFileInputs() { const inputs = []; const fileInputs = $$('input[type="file"].hidden'); fileInputs.forEach((input, index) => { const context = determineFileInputContext(input); inputs.push({ element: input, context, index }); debugLog(`File input ${index}: context=${context}`); }); return inputs; } /** * Determine which context a file input belongs to */ function determineFileInputContext(input) { // Check if in Knowledge tab section (look for "Files" header nearby) const parent = input.parentElement; if (parent) { // Knowledge section has h3 "Files" header const h3 = parent.querySelector('h3'); if (h3 && h3.textContent.includes('Files')) { return FILE_CONTEXTS.KNOWLEDGE; } // Knowledge section also has "Add File" button const addFileText = parent.querySelector('.text-blue-100'); if (addFileText && addFileText.textContent.includes('Add File')) { return FILE_CONTEXTS.KNOWLEDGE; } } // Check if associated with a chat textarea const container = input.closest('.border.rounded-3xl'); if (container) { const textarea = container.querySelector('#autoselect-experience'); if (textarea) { // Check if this is the builder chat (has "Test" button nearby) const testBtn = container.querySelector('[data-tip*="test"], .tooltip'); if (testBtn) { return FILE_CONTEXTS.BUILDER; } return FILE_CONTEXTS.CHAT; } } // Default to chat return FILE_CONTEXTS.CHAT; } /** * Find the best file input to use based on drop location */ function findFileInputNearPoint(x, y) { const allInputs = getAllFileInputs(); if (allInputs.length === 0) return null; if (allInputs.length === 1) return allInputs[0].element; // Check which tab is active (Knowledge vs Create/General) const knowledgeTab = $('[data-automation-id="Knowledge"][aria-selected="true"]'); if (knowledgeTab) { // Prefer knowledge file input const knowledgeInput = allInputs.find(i => i.context === FILE_CONTEXTS.KNOWLEDGE); if (knowledgeInput) { debugLog('Knowledge tab active - using knowledge file input'); return knowledgeInput.element; } } // Try to find the closest chat input container to the drop point const chatContainers = $$('.border.rounded-3xl'); let closestContainer = null; let closestDistance = Infinity; chatContainers.forEach(container => { const rect = container.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const distance = Math.sqrt(Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2)); if (distance < closestDistance) { closestDistance = distance; closestContainer = container; } }); if (closestContainer) { const input = closestContainer.querySelector('input[type="file"].hidden'); if (input) { debugLog('Found closest file input to drop point'); return input; } } // Fallback to first available debugLog('Using first available file input'); return allInputs[0].element; } function getInputContainer() { const textarea = getTextarea(); if (textarea) { let el = textarea.parentElement; while (el) { if (el.classList.contains('rounded-3xl') || el.className.includes('rounded-3xl')) { return el; } el = el.parentElement; } } return $(SELECTORS.inputContainer); } function getInputWrapper() { const textarea = getTextarea(); if (textarea) { let el = textarea.parentElement; while (el) { const style = el.getAttribute('class') || ''; if (style.includes('min-w-[200px]') || style.includes('max-w-[840px]')) { return el; } el = el.parentElement; } } return null; } // ---- Styles ---- function injectStyles() { if (document.getElementById('ninjacat-chat-ux-styles')) return; const styles = document.createElement('style'); styles.id = 'ninjacat-chat-ux-styles'; styles.textContent = ` /* Drop Zone Overlay */ .nc-drop-zone { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(59, 130, 246, 0.15); border: 4px dashed #3B82F6; display: none; align-items: center; justify-content: center; z-index: 9999; pointer-events: none; transition: opacity 0.2s ease; } .nc-drop-zone.visible { display: flex; pointer-events: auto; } .nc-drop-zone-content { background: white; padding: 32px 48px; border-radius: 16px; box-shadow: 0 8px 32px rgba(0,0,0,0.2); text-align: center; } .nc-drop-zone-icon { font-size: 48px; margin-bottom: 16px; } .nc-drop-zone-text { font-size: 18px; font-weight: 600; color: #1F2937; margin-bottom: 8px; } .nc-drop-zone-hint { font-size: 14px; color: #6B7280; } .nc-drop-zone-context { margin-top: 12px; font-size: 13px; font-weight: 500; } /* Message Queue UI */ .nc-queue-container { margin-top: 8px; padding: 0 20px; } .nc-queue-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #FEF3C7; border-radius: 8px 8px 0 0; border: 1px solid #FCD34D; border-bottom: none; } .nc-queue-title { font-size: 12px; font-weight: 600; color: #92400E; } .nc-queue-actions { display: flex; gap: 8px; } .nc-queue-btn { padding: 4px 8px; font-size: 11px; border-radius: 4px; cursor: pointer; border: none; font-weight: 500; } .nc-queue-btn-resume { background: #10B981; color: white; } .nc-queue-btn-clear { background: #EF4444; color: white; } .nc-queue-list { border: 1px solid #FCD34D; border-radius: 0 0 8px 8px; overflow: hidden; } .nc-queue-item { display: flex; align-items: center; padding: 8px 12px; background: #FFFBEB; border-bottom: 1px solid #FEF3C7; gap: 8px; } .nc-queue-item:last-child { border-bottom: none; } .nc-queue-item-number { width: 20px; height: 20px; background: #F59E0B; color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600; flex-shrink: 0; } .nc-queue-item-text { flex: 1; font-size: 13px; color: #374151; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .nc-queue-item-actions { display: flex; gap: 4px; flex-shrink: 0; } .nc-queue-item-btn { padding: 2px 6px; font-size: 10px; border-radius: 3px; cursor: pointer; border: 1px solid #D1D5DB; background: white; } .nc-queue-item-btn:hover { background: #F3F4F6; } /* Toast notifications */ .nc-toast { position: fixed; bottom: 100px; left: 50%; transform: translateX(-50%); background: #1F2937; color: white; padding: 12px 20px; border-radius: 8px; font-size: 14px; z-index: 10000; opacity: 0; transition: opacity 0.3s ease; pointer-events: none; } .nc-toast.visible { opacity: 1; } .nc-toast.error { background: #DC2626; } .nc-toast.success { background: #059669; } /* Error State Warning Banner */ .nc-error-state-warning { margin: 8px 20px; padding: 10px 14px; background: #FEF3C7; border: 1px solid #F59E0B; border-radius: 8px; display: flex; align-items: center; gap: 10px; font-size: 13px; color: #92400E; } .nc-error-state-warning-icon { font-size: 16px; flex-shrink: 0; } .nc-error-state-warning-text { flex: 1; } .nc-error-state-warning-text strong { display: block; margin-bottom: 2px; } .nc-error-state-warning-text small { color: #B45309; } `; document.head.appendChild(styles); } // ---- Toast Notifications ---- let toastTimeout = null; function showToast(message, type = 'info') { let toast = document.getElementById('nc-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'nc-toast'; toast.className = 'nc-toast'; document.body.appendChild(toast); } toast.textContent = message; toast.className = `nc-toast ${type}`; if (toastTimeout) clearTimeout(toastTimeout); requestAnimationFrame(() => { toast.classList.add('visible'); }); toastTimeout = setTimeout(() => { toast.classList.remove('visible'); }, 3000); } // ---- Drop Zone ---- function createDropZone() { if (document.getElementById('nc-drop-zone')) return; const dropZone = document.createElement('div'); dropZone.id = 'nc-drop-zone'; dropZone.className = 'nc-drop-zone'; dropZone.innerHTML = `