feat: implement enhanced message controls with regenerate any message
Added comprehensive message control features for fine-grained conversation management: Backend (Rust): - Extended Message struct with 'pinned' and 'hidden' boolean fields - Added delete_message_at_index() command for removing any message - Added toggle_message_pin() command to mark important messages - Added toggle_message_hidden() command to temporarily hide messages - Added continue_message() command to append AI continuations - Added regenerate_at_index() command to regenerate ANY message (not just last) Frontend (JavaScript): - Added delete, pin, hide buttons to all messages (user & assistant) - Added continue button for assistant messages - Updated regenerate to work on any message, not just the last one - Implemented state persistence for pinned/hidden in chat history - Added dynamic icon changes for hide/unhide states - Integrated with token counter for real-time updates UI/UX (CSS): - Pinned messages: accent-colored left border with glow effect - Hidden messages: 40% opacity with blur effect (70% on hover) - Delete button: red hover warning (#ef4444) - Active state indicators for pin/hide buttons - Always-visible controls on hidden messages for quick access Features: - Delete any message with confirmation dialog - Pin messages to always keep them in context - Hide messages with visual blur (still in context but dimmed) - Continue incomplete assistant responses - Regenerate any assistant message (creates new swipe) - All states persist in chat history JSON This completes Phase 3.2 "Enhanced Message Controls" from the roadmap, providing users with complete control over their conversation history. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
256
src/main.js
256
src/main.js
@@ -481,6 +481,37 @@ function addMessage(content, isUser = false, skipActions = false, timestamp = nu
|
||||
editBtn.addEventListener('click', () => handleEditMessage(messageDiv, content));
|
||||
actionsDiv.appendChild(editBtn);
|
||||
|
||||
// Pin button
|
||||
const pinBtn = document.createElement('button');
|
||||
pinBtn.className = 'message-action-btn message-pin-btn';
|
||||
pinBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M7 11V5M4.5 5L7 2.5L9.5 5M7 11L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`;
|
||||
pinBtn.title = 'Pin message';
|
||||
pinBtn.addEventListener('click', () => handleTogglePin(messageDiv));
|
||||
actionsDiv.appendChild(pinBtn);
|
||||
|
||||
// Hide button
|
||||
const hideBtn = document.createElement('button');
|
||||
hideBtn.className = 'message-action-btn message-hide-btn';
|
||||
hideBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M1 7C1 7 3 3 7 3C11 3 13 7 13 7C13 7 11 11 7 11C3 11 1 7 1 7Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="7" cy="7" r="2" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>`;
|
||||
hideBtn.title = 'Hide message';
|
||||
hideBtn.addEventListener('click', () => handleToggleHidden(messageDiv));
|
||||
actionsDiv.appendChild(hideBtn);
|
||||
|
||||
// Delete button
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'message-action-btn message-delete-btn';
|
||||
deleteBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2 4H12M5 4V3C5 2.44772 5.44772 2 6 2H8C8.55228 2 9 2.44772 9 3V4M11 4L10.5 11C10.5 11.5523 10.0523 12 9.5 12H4.5C3.94772 12 3.5 11.5523 3.5 11L3 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`;
|
||||
deleteBtn.title = 'Delete message';
|
||||
deleteBtn.addEventListener('click', () => handleDeleteMessage(messageDiv));
|
||||
actionsDiv.appendChild(deleteBtn);
|
||||
|
||||
messageDiv.appendChild(contentDiv);
|
||||
messageDiv.appendChild(actionsDiv);
|
||||
} else {
|
||||
@@ -495,6 +526,47 @@ function addMessage(content, isUser = false, skipActions = false, timestamp = nu
|
||||
regenerateBtn.addEventListener('click', () => handleRegenerateMessage(messageDiv));
|
||||
actionsDiv.appendChild(regenerateBtn);
|
||||
|
||||
// Continue button
|
||||
const continueBtn = document.createElement('button');
|
||||
continueBtn.className = 'message-action-btn message-continue-btn';
|
||||
continueBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M3 7H11M11 7L7 3M11 7L7 11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`;
|
||||
continueBtn.title = 'Continue message';
|
||||
continueBtn.addEventListener('click', () => handleContinueMessage(messageDiv));
|
||||
actionsDiv.appendChild(continueBtn);
|
||||
|
||||
// Pin button
|
||||
const pinBtn = document.createElement('button');
|
||||
pinBtn.className = 'message-action-btn message-pin-btn';
|
||||
pinBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M7 11V5M4.5 5L7 2.5L9.5 5M7 11L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`;
|
||||
pinBtn.title = 'Pin message';
|
||||
pinBtn.addEventListener('click', () => handleTogglePin(messageDiv));
|
||||
actionsDiv.appendChild(pinBtn);
|
||||
|
||||
// Hide button
|
||||
const hideBtn = document.createElement('button');
|
||||
hideBtn.className = 'message-action-btn message-hide-btn';
|
||||
hideBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M1 7C1 7 3 3 7 3C11 3 13 7 13 7C13 7 11 11 7 11C3 11 1 7 1 7Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="7" cy="7" r="2" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>`;
|
||||
hideBtn.title = 'Hide message';
|
||||
hideBtn.addEventListener('click', () => handleToggleHidden(messageDiv));
|
||||
actionsDiv.appendChild(hideBtn);
|
||||
|
||||
// Delete button
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'message-action-btn message-delete-btn';
|
||||
deleteBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2 4H12M5 4V3C5 2.44772 5.44772 2 6 2H8C8.55228 2 9 2.44772 9 3V4M11 4L10.5 11C10.5 11.5523 10.0523 12 9.5 12H4.5C3.94772 12 3.5 11.5523 3.5 11L3 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`;
|
||||
deleteBtn.title = 'Delete message';
|
||||
deleteBtn.addEventListener('click', () => handleDeleteMessage(messageDiv));
|
||||
actionsDiv.appendChild(deleteBtn);
|
||||
|
||||
// Create swipe wrapper
|
||||
const swipeWrapper = document.createElement('div');
|
||||
swipeWrapper.style.display = 'flex';
|
||||
@@ -695,21 +767,39 @@ async function handleEditMessage(messageDiv, originalContent) {
|
||||
|
||||
// Handle regenerating an assistant message
|
||||
async function handleRegenerateMessage(messageDiv) {
|
||||
const allMessages = Array.from(messagesContainer.querySelectorAll('.message'));
|
||||
const messageIndex = allMessages.indexOf(messageDiv);
|
||||
|
||||
if (messageIndex === -1) {
|
||||
console.error('Message not found in list');
|
||||
return;
|
||||
}
|
||||
|
||||
const regenerateBtn = messageDiv.querySelector('.message-action-btn');
|
||||
regenerateBtn.disabled = true;
|
||||
regenerateBtn.classList.add('loading');
|
||||
|
||||
try {
|
||||
// Get the last user message
|
||||
const lastUserMessage = await invoke('get_last_user_message');
|
||||
setStatus('Regenerating response...', 'default');
|
||||
|
||||
// Generate new response
|
||||
await generateSwipe(messageDiv, lastUserMessage);
|
||||
// Use the new regenerate_at_index command which works on any message
|
||||
const swipeInfo = await invoke('regenerate_at_index', { messageIndex });
|
||||
|
||||
// Update the message content
|
||||
const contentDiv = messageDiv.querySelector('.message-content');
|
||||
renderAssistantContent(contentDiv, swipeInfo.content);
|
||||
|
||||
// Update swipe controls
|
||||
updateSwipeControls(messageDiv, swipeInfo.current, swipeInfo.total);
|
||||
|
||||
setStatus('Regeneration complete', 'success');
|
||||
setTimeout(() => setStatus('Ready'), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to regenerate message:', error);
|
||||
setStatus(`Regeneration failed: ${error}`, 'error');
|
||||
} finally {
|
||||
regenerateBtn.disabled = false;
|
||||
regenerateBtn.classList.remove('loading');
|
||||
addMessage(`Error regenerating message: ${error}`, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -853,6 +943,139 @@ function addCopyButtonToCode(block) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deleting a message
|
||||
async function handleDeleteMessage(messageDiv) {
|
||||
const allMessages = Array.from(messagesContainer.querySelectorAll('.message'));
|
||||
const messageIndex = allMessages.indexOf(messageDiv);
|
||||
|
||||
if (messageIndex === -1) {
|
||||
console.error('Message not found in list');
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm deletion
|
||||
if (!confirm('Are you sure you want to delete this message? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('delete_message_at_index', { messageIndex });
|
||||
messageDiv.remove();
|
||||
await updateTokenCount();
|
||||
setStatus('Message deleted', 'success');
|
||||
setTimeout(() => setStatus('Ready'), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete message:', error);
|
||||
setStatus(`Delete failed: ${error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle toggling message pin status
|
||||
async function handleTogglePin(messageDiv) {
|
||||
const allMessages = Array.from(messagesContainer.querySelectorAll('.message'));
|
||||
const messageIndex = allMessages.indexOf(messageDiv);
|
||||
|
||||
if (messageIndex === -1) {
|
||||
console.error('Message not found in list');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const isPinned = await invoke('toggle_message_pin', { messageIndex });
|
||||
|
||||
// Update visual indicator
|
||||
const pinBtn = messageDiv.querySelector('.message-pin-btn');
|
||||
if (isPinned) {
|
||||
messageDiv.classList.add('pinned');
|
||||
pinBtn.classList.add('active');
|
||||
pinBtn.title = 'Unpin message';
|
||||
} else {
|
||||
messageDiv.classList.remove('pinned');
|
||||
pinBtn.classList.remove('active');
|
||||
pinBtn.title = 'Pin message';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle pin:', error);
|
||||
setStatus(`Pin toggle failed: ${error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle toggling message hidden status
|
||||
async function handleToggleHidden(messageDiv) {
|
||||
const allMessages = Array.from(messagesContainer.querySelectorAll('.message'));
|
||||
const messageIndex = allMessages.indexOf(messageDiv);
|
||||
|
||||
if (messageIndex === -1) {
|
||||
console.error('Message not found in list');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const isHidden = await invoke('toggle_message_hidden', { messageIndex });
|
||||
|
||||
// Update visual indicator
|
||||
const hideBtn = messageDiv.querySelector('.message-hide-btn');
|
||||
if (isHidden) {
|
||||
messageDiv.classList.add('hidden-message');
|
||||
hideBtn.classList.add('active');
|
||||
hideBtn.title = 'Unhide message';
|
||||
// Update icon to "eye-off"
|
||||
hideBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M10 5L11.5 3.5M3.5 10.5L5 9M1 13L13 1M5.5 6C5.19 6.31 5 6.74 5 7.22C5 8.2 5.8 9 6.78 9C7.26 9 7.69 8.81 8 8.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>`;
|
||||
} else {
|
||||
messageDiv.classList.remove('hidden-message');
|
||||
hideBtn.classList.remove('active');
|
||||
hideBtn.title = 'Hide message';
|
||||
// Update icon back to "eye"
|
||||
hideBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M1 7C1 7 3 3 7 3C11 3 13 7 13 7C13 7 11 11 7 11C3 11 1 7 1 7Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="7" cy="7" r="2" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
await updateTokenCount();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle hidden:', error);
|
||||
setStatus(`Hide toggle failed: ${error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle continuing an incomplete message
|
||||
async function handleContinueMessage(messageDiv) {
|
||||
const allMessages = Array.from(messagesContainer.querySelectorAll('.message'));
|
||||
const messageIndex = allMessages.indexOf(messageDiv);
|
||||
|
||||
if (messageIndex === -1) {
|
||||
console.error('Message not found in list');
|
||||
return;
|
||||
}
|
||||
|
||||
const continueBtn = messageDiv.querySelector('.message-continue-btn');
|
||||
continueBtn.disabled = true;
|
||||
continueBtn.classList.add('loading');
|
||||
|
||||
try {
|
||||
setStatus('Continuing message...', 'default');
|
||||
const continuedText = await invoke('continue_message', { messageIndex });
|
||||
|
||||
// The backend appends to the message, so we just need to reload the content
|
||||
const contentDiv = messageDiv.querySelector('.message-content');
|
||||
const swipeInfo = await invoke('get_swipe_info', { messageIndex });
|
||||
renderAssistantContent(contentDiv, swipeInfo.content);
|
||||
|
||||
setStatus('Message continued', 'success');
|
||||
setTimeout(() => setStatus('Ready'), 2000);
|
||||
await updateTokenCount();
|
||||
} catch (error) {
|
||||
console.error('Failed to continue message:', error);
|
||||
setStatus(`Continue failed: ${error}`, 'error');
|
||||
} finally {
|
||||
continueBtn.disabled = false;
|
||||
continueBtn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
// Extract message sending logic into separate function
|
||||
async function sendMessage(message, isRegenerate = false) {
|
||||
if (!isRegenerate) {
|
||||
@@ -1582,6 +1805,29 @@ async function loadChatHistory() {
|
||||
history.forEach((msg, index) => {
|
||||
const messageDiv = addMessage(msg.content, msg.role === 'user', false, msg.timestamp);
|
||||
|
||||
// Apply pinned state
|
||||
if (msg.pinned && messageDiv) {
|
||||
messageDiv.classList.add('pinned');
|
||||
const pinBtn = messageDiv.querySelector('.message-pin-btn');
|
||||
if (pinBtn) {
|
||||
pinBtn.classList.add('active');
|
||||
pinBtn.title = 'Unpin message';
|
||||
}
|
||||
}
|
||||
|
||||
// Apply hidden state
|
||||
if (msg.hidden && messageDiv) {
|
||||
messageDiv.classList.add('hidden-message');
|
||||
const hideBtn = messageDiv.querySelector('.message-hide-btn');
|
||||
if (hideBtn) {
|
||||
hideBtn.classList.add('active');
|
||||
hideBtn.title = 'Unhide message';
|
||||
hideBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M10 5L11.5 3.5M3.5 10.5L5 9M1 13L13 1M5.5 6C5.19 6.31 5 6.74 5 7.22C5 8.2 5.8 9 6.78 9C7.26 9 7.69 8.81 8 8.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update swipe controls for assistant messages with swipe info
|
||||
if (msg.role === 'assistant' && messageDiv && msg.swipes && msg.swipes.length > 0) {
|
||||
updateSwipeControls(messageDiv, msg.current_swipe || 0, msg.swipes.length);
|
||||
|
||||
Reference in New Issue
Block a user