diff --git a/app/scripts/modules/ui/AIChat.js b/app/scripts/modules/ui/AIChat.js index 57d1588b..290ed8f6 100644 --- a/app/scripts/modules/ui/AIChat.js +++ b/app/scripts/modules/ui/AIChat.js @@ -323,7 +323,7 @@ AIChat.prototype._handleSendMessage = async function () { // Create placeholder for AI response without copy button (will add after completion) const messageElement = this._addMessage('assistant', '', false); this._streamingMessageElement = messageElement.querySelector('.message-content'); - this._streamingMessageHeader = messageElement.querySelector('.message-header'); + this._streamingMessageHeader = messageElement; // Add loading indicator as DOM elements (not HTML string to avoid escaping) const loadingIndicator = document.createElement('span'); @@ -372,16 +372,19 @@ AIChat.prototype._handleSendMessage = async function () { this._streamingMessageElement.innerHTML = this._parseMarkdown(fullResponse); this._initializeJsonViewers(this._streamingMessageElement); - // Add copy button now that response is complete - const copyButton = document.createElement('button'); - copyButton.className = 'copy-response-button'; - copyButton.title = 'Copy response'; - copyButton.setAttribute('aria-label', 'Copy response'); - copyButton.textContent = 'Copy'; - copyButton.addEventListener('click', (e) => { - this._copyToClipboard(fullResponse, e.currentTarget); - }); - this._streamingMessageHeader.appendChild(copyButton); + // Add copy button now that response is complete, unless it's only a code/JSON block + const isOnlyBlock = this._isOnlyCodeOrJsonBlock(this._streamingMessageElement); + if (!isOnlyBlock) { + const copyButton = document.createElement('button'); + copyButton.className = 'copy-response-button'; + copyButton.title = 'Copy response'; + copyButton.setAttribute('aria-label', 'Copy response'); + copyButton.textContent = 'Copy'; + copyButton.addEventListener('click', (e) => { + this._copyToClipboard(fullResponse, e.currentTarget); + }); + this._streamingMessageHeader.appendChild(copyButton); + } // Save AI response to storage await this._storageManager.saveMessage(this._currentUrl, { @@ -503,6 +506,22 @@ AIChat.prototype._renderModelStatus = function (status, progress, message) { } }; +/** + * Check if a message content element contains only a single code or JSON block with no other text. + * @private + * @param {HTMLElement} contentElement + * @returns {boolean} + */ +AIChat.prototype._isOnlyCodeOrJsonBlock = function (contentElement) { + var clone = contentElement.cloneNode(true); + var blocks = clone.querySelectorAll('.code-viewer, .json-viewer'); + if (blocks.length !== 1) { + return false; + } + blocks[0].remove(); + return clone.textContent.replace(/\s/g, '') === ''; +}; + /** * Add a message to the chat UI. * @param {string} role - 'user', 'assistant', or 'system' @@ -531,7 +550,6 @@ AIChat.prototype._addMessage = function (role, content, showCopyButton) { messageElement.innerHTML = `
`; @@ -543,12 +561,19 @@ AIChat.prototype._addMessage = function (role, content, showCopyButton) { const contentElement = messageElement.querySelector('.message-content'); this._initializeJsonViewers(contentElement); - // Add copy button event listener if button exists - const copyButton = messageElement.querySelector('.copy-response-button'); - if (copyButton) { - copyButton.addEventListener('click', (e) => { - this._copyToClipboard(content, e.currentTarget); - }); + if (shouldShowCopyButton) { + const isOnlyBlock = this._isOnlyCodeOrJsonBlock(contentElement); + if (!isOnlyBlock) { + const copyButton = document.createElement('button'); + copyButton.className = 'copy-response-button'; + copyButton.title = 'Copy response'; + copyButton.setAttribute('aria-label', 'Copy response'); + copyButton.textContent = 'Copy'; + copyButton.addEventListener('click', (e) => { + this._copyToClipboard(content, e.currentTarget); + }); + messageElement.appendChild(copyButton); + } } } diff --git a/app/styles/less/modules/AIChat.less b/app/styles/less/modules/AIChat.less index 2a6d2b4e..aa9ab5a5 100644 --- a/app/styles/less/modules/AIChat.less +++ b/app/styles/less/modules/AIChat.less @@ -250,26 +250,28 @@ .message-role { font-weight: 600; } +} - .copy-response-button { - padding: 4px 8px; - border: 1px solid @border-gray-lighter; - background-color: @bg-white; - color: @text-color; - border-radius: 4px; - cursor: pointer; - font-size: 11px; - transition: all 0.2s; +.copy-response-button { + display: block; + margin: 6px auto 4px 0px; + padding: 4px 8px; + border: 1px solid @border-gray-lighter; + background-color: @bg-white; + color: @text-color; + border-radius: 4px; + cursor: pointer; + font-size: 11px; + transition: all 0.2s; - &:hover { - background-color: @lighter-blue; - color: @white; - border-color: @lighter-blue; - } + &:hover { + background-color: @lighter-blue; + color: @white; + border-color: @lighter-blue; + } - &:active { - transform: scale(0.95); - } + &:active { + transform: scale(0.95); } } @@ -538,21 +540,21 @@ .copy-code-button { position: absolute; top: 6px; - right: 6px; - background: @lighter-blue; - color: @white; - border: none; - border-radius: 4px; - padding: 4px 10px; + right: 13px; + background: transparent; + color: @gray-dark; + border: 1px solid @border-gray-lighter; + border-radius: 3px; + padding: 2px 7px; cursor: pointer; - font-size: 11px; + font-size: 10px; z-index: 10; transition: all 0.2s; - opacity: 0.9; &:hover { - background: darken(@lighter-blue, 10%); - opacity: 1; + background: @lighter-blue; + color: @white; + border-color: @lighter-blue; } &:active { @@ -649,21 +651,21 @@ .copy-code-button { position: absolute; top: 6px; - right: 6px; - background: @lighter-blue; - color: @white; - border: none; - border-radius: 4px; - padding: 4px 10px; + right: 13px; + background: transparent; + color: @gray-dark; + border: 1px solid @border-gray-lighter; + border-radius: 3px; + padding: 2px 7px; cursor: pointer; - font-size: 11px; + font-size: 10px; z-index: 10; transition: all 0.2s; - opacity: 0.9; &:hover { - background: darken(@lighter-blue, 10%); - opacity: 1; + background: @lighter-blue; + color: @white; + border-color: @lighter-blue; } &:active { diff --git a/tests/modules/ui/AIChat.spec.js b/tests/modules/ui/AIChat.spec.js index a97dce47..ce9d1fe1 100644 --- a/tests/modules/ui/AIChat.spec.js +++ b/tests/modules/ui/AIChat.spec.js @@ -239,6 +239,56 @@ describe('AIChat', function () { var container = document.getElementById('ai-messages-container'); container.innerHTML.should.contain('bold'); }); + + it('should place copy-response-button after message-content for assistant messages', function () { + var messageEl = aiChat._addMessage('assistant', 'Some text response'); + var content = messageEl.querySelector('.message-content'); + var copyBtn = messageEl.querySelector('.copy-response-button'); + copyBtn.should.exist; + (content.compareDocumentPosition(copyBtn) & Node.DOCUMENT_POSITION_FOLLOWING).should.be.ok; + }); + + it('should not place copy-response-button inside message-header', function () { + var messageEl = aiChat._addMessage('assistant', 'Some text response'); + var header = messageEl.querySelector('.message-header'); + (header.querySelector('.copy-response-button') === null).should.be.true; + }); + + it('should hide copy-response-button when response is only a single code block', function () { + var messageEl = aiChat._addMessage('assistant', '```javascript\nvar x = 1;\n```'); + var copyBtn = messageEl.querySelector('.copy-response-button'); + (copyBtn === null).should.be.true; + }); + + it('should hide copy-response-button when response is only a single JSON block', function () { + var messageEl = aiChat._addMessage('assistant', '```json\n{"key": "value"}\n```'); + var copyBtn = messageEl.querySelector('.copy-response-button'); + (copyBtn === null).should.be.true; + }); + + it('should show copy-response-button when response has text mixed with code', function () { + var messageEl = aiChat._addMessage('assistant', 'Here is an example:\n```javascript\nvar x = 1;\n```'); + var copyBtn = messageEl.querySelector('.copy-response-button'); + copyBtn.should.exist; + }); + }); + }); + + describe('Copy button styling', function () { + it('copy-code-button inside code-viewer should not have filled background style in HTML', function () { + var result = aiChat._renderCodeBlock('var x = 1;', 'javascript'); + result.should.contain('copy-code-button'); + result.should.not.contain('background:'); + result.should.not.contain('background-color:'); + }); + + it('copy-code-button inside json-viewer wrapper should not have inline filled style', function () { + var container = document.createElement('div'); + container.innerHTML = aiChat._createJsonViewer({a: 1}); + aiChat._initializeJsonViewers(container); + var btn = container.querySelector('.copy-code-button'); + btn.should.exist; + (btn.style.backgroundColor === '' || btn.style.backgroundColor === undefined).should.be.true; }); }); diff --git a/tests/styles/themes/dark/dark.css b/tests/styles/themes/dark/dark.css index 83f4e5ff..e83ebfe8 100644 --- a/tests/styles/themes/dark/dark.css +++ b/tests/styles/themes/dark/dark.css @@ -1482,7 +1482,9 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio .message-header .message-role { font-weight: 600; } -.message-header .copy-response-button { +.copy-response-button { + display: block; + margin: 6px auto 4px 0px; padding: 4px 8px; border: 1px solid #d4d4d4; background-color: #252525; @@ -1492,12 +1494,12 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio font-size: 11px; transition: all 0.2s; } -.message-header .copy-response-button:hover { +.copy-response-button:hover { background-color: #C98730; color: #fff; border-color: #C98730; } -.message-header .copy-response-button:active { +.copy-response-button:active { transform: scale(0.95); } .message-content { @@ -1722,21 +1724,21 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio .json-viewer .copy-code-button { position: absolute; top: 6px; - right: 6px; - background: #C98730; - color: #fff; - border: none; - border-radius: 4px; - padding: 4px 10px; + right: 13px; + background: transparent; + color: #a3a3a3; + border: 1px solid #d4d4d4; + border-radius: 3px; + padding: 2px 7px; cursor: pointer; - font-size: 11px; + font-size: 10px; z-index: 10; transition: all 0.2s; - opacity: 0.9; } .json-viewer .copy-code-button:hover { - background: #a06b26; - opacity: 1; + background: #C98730; + color: #fff; + border-color: #C98730; } .json-viewer .copy-code-button:active { transform: scale(0.95); @@ -1813,21 +1815,21 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio .code-viewer .copy-code-button { position: absolute; top: 6px; - right: 6px; - background: #C98730; - color: #fff; - border: none; - border-radius: 4px; - padding: 4px 10px; + right: 13px; + background: transparent; + color: #a3a3a3; + border: 1px solid #d4d4d4; + border-radius: 3px; + padding: 2px 7px; cursor: pointer; - font-size: 11px; + font-size: 10px; z-index: 10; transition: all 0.2s; - opacity: 0.9; } .code-viewer .copy-code-button:hover { - background: #a06b26; - opacity: 1; + background: #C98730; + color: #fff; + border-color: #C98730; } .code-viewer .copy-code-button:active { transform: scale(0.95); diff --git a/tests/styles/themes/light/light.css b/tests/styles/themes/light/light.css index 9cf3479f..0abfa3c2 100644 --- a/tests/styles/themes/light/light.css +++ b/tests/styles/themes/light/light.css @@ -1482,7 +1482,9 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio .message-header .message-role { font-weight: 600; } -.message-header .copy-response-button { +.copy-response-button { + display: block; + margin: 6px auto 4px 0px; padding: 4px 8px; border: 1px solid #d4d4d4; background-color: #fff; @@ -1492,12 +1494,12 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio font-size: 11px; transition: all 0.2s; } -.message-header .copy-response-button:hover { +.copy-response-button:hover { background-color: #3879D9; color: #fff; border-color: #3879D9; } -.message-header .copy-response-button:active { +.copy-response-button:active { transform: scale(0.95); } .message-content { @@ -1722,21 +1724,21 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio .json-viewer .copy-code-button { position: absolute; top: 6px; - right: 6px; - background: #3879D9; - color: #fff; - border: none; - border-radius: 4px; - padding: 4px 10px; + right: 13px; + background: transparent; + color: #a3a3a3; + border: 1px solid #d4d4d4; + border-radius: 3px; + padding: 2px 7px; cursor: pointer; - font-size: 11px; + font-size: 10px; z-index: 10; transition: all 0.2s; - opacity: 0.9; } .json-viewer .copy-code-button:hover { - background: #2460ba; - opacity: 1; + background: #3879D9; + color: #fff; + border-color: #3879D9; } .json-viewer .copy-code-button:active { transform: scale(0.95); @@ -1813,21 +1815,21 @@ elements-registry-master-view:not([show-filtered-elements]) .selected ::selectio .code-viewer .copy-code-button { position: absolute; top: 6px; - right: 6px; - background: #3879D9; - color: #fff; - border: none; - border-radius: 4px; - padding: 4px 10px; + right: 13px; + background: transparent; + color: #a3a3a3; + border: 1px solid #d4d4d4; + border-radius: 3px; + padding: 2px 7px; cursor: pointer; - font-size: 11px; + font-size: 10px; z-index: 10; transition: all 0.2s; - opacity: 0.9; } .code-viewer .copy-code-button:hover { - background: #2460ba; - opacity: 1; + background: #3879D9; + color: #fff; + border-color: #3879D9; } .code-viewer .copy-code-button:active { transform: scale(0.95);