Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 43 additions & 18 deletions app/scripts/modules/ui/AIChat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -531,7 +550,6 @@ AIChat.prototype._addMessage = function (role, content, showCopyButton) {
messageElement.innerHTML = `
<div class="message-header">
<span class="message-role">${role === 'user' ? 'You' : role === 'assistant' ? 'AI' : 'System'}</span>
${shouldShowCopyButton ? '<button class="copy-response-button" title="Copy response" aria-label="Copy response">Copy</button>' : ''}
</div>
<div class="message-content">${formattedContent}</div>
`;
Expand All @@ -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);
}
}
}

Expand Down
76 changes: 39 additions & 37 deletions app/styles/less/modules/AIChat.less
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
50 changes: 50 additions & 0 deletions tests/modules/ui/AIChat.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,56 @@ describe('AIChat', function () {
var container = document.getElementById('ai-messages-container');
container.innerHTML.should.contain('<strong>bold</strong>');
});

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;
});
});

Expand Down
48 changes: 25 additions & 23 deletions tests/styles/themes/dark/dark.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading