feat(chat): add conversation pinning and renaming
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Users can now pin favorite conversations (shown at top with section header) and rename them with inline editing. Adds is_pinned, pinned_at, custom_name columns to ai_chat_conversations table. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
41f5b688a8
commit
6807506913
@ -279,14 +279,20 @@ def chat_list_conversations():
|
||||
try:
|
||||
conversations = db.query(AIChatConversation).filter_by(
|
||||
user_id=current_user.id
|
||||
).order_by(AIChatConversation.updated_at.desc()).limit(50).all()
|
||||
).order_by(
|
||||
AIChatConversation.is_pinned.desc().nullslast(),
|
||||
AIChatConversation.updated_at.desc()
|
||||
).limit(50).all()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'conversations': [
|
||||
{
|
||||
'id': c.id,
|
||||
'title': c.title,
|
||||
'title': c.custom_name or c.title,
|
||||
'original_title': c.title,
|
||||
'custom_name': c.custom_name,
|
||||
'is_pinned': c.is_pinned or False,
|
||||
'created_at': c.started_at.isoformat() if c.started_at else None,
|
||||
'updated_at': c.updated_at.isoformat() if c.updated_at else None,
|
||||
'message_count': len(c.messages) if c.messages else 0
|
||||
@ -301,6 +307,70 @@ def chat_list_conversations():
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/api/chat/<int:conversation_id>/rename', methods=['PATCH'])
|
||||
@login_required
|
||||
@member_required
|
||||
def chat_rename_conversation(conversation_id):
|
||||
"""Rename a conversation"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
conversation = db.query(AIChatConversation).filter_by(
|
||||
id=conversation_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not conversation:
|
||||
return jsonify({'success': False, 'error': 'Conversation not found'}), 404
|
||||
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'success': False, 'error': 'Nazwa nie może być pusta'}), 400
|
||||
|
||||
if len(name) > 255:
|
||||
name = name[:255]
|
||||
|
||||
conversation.custom_name = name
|
||||
db.commit()
|
||||
|
||||
return jsonify({'success': True, 'name': name})
|
||||
except Exception as e:
|
||||
logger.error(f"Error renaming conversation: {e}")
|
||||
db.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/api/chat/<int:conversation_id>/pin', methods=['PATCH'])
|
||||
@login_required
|
||||
@member_required
|
||||
def chat_pin_conversation(conversation_id):
|
||||
"""Pin or unpin a conversation"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
conversation = db.query(AIChatConversation).filter_by(
|
||||
id=conversation_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not conversation:
|
||||
return jsonify({'success': False, 'error': 'Conversation not found'}), 404
|
||||
|
||||
conversation.is_pinned = not conversation.is_pinned
|
||||
conversation.pinned_at = datetime.now() if conversation.is_pinned else None
|
||||
db.commit()
|
||||
|
||||
return jsonify({'success': True, 'is_pinned': conversation.is_pinned})
|
||||
except Exception as e:
|
||||
logger.error(f"Error pinning conversation: {e}")
|
||||
db.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/api/chat/<int:conversation_id>/delete', methods=['DELETE'])
|
||||
@login_required
|
||||
@member_required
|
||||
|
||||
@ -1518,6 +1518,11 @@ class AIChatConversation(Base):
|
||||
message_count = Column(Integer, default=0)
|
||||
model_name = Column(String(100))
|
||||
|
||||
# Pin & custom name
|
||||
is_pinned = Column(Boolean, default=False)
|
||||
pinned_at = Column(DateTime)
|
||||
custom_name = Column(String(255))
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', back_populates='conversations')
|
||||
messages = relationship('AIChatMessage', back_populates='conversation', cascade='all, delete-orphan', order_by='AIChatMessage.created_at')
|
||||
|
||||
12
database/migrations/084_chat_pin_rename.sql
Normal file
12
database/migrations/084_chat_pin_rename.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- Migration 084: Add pinning and custom naming to chat conversations
|
||||
-- Date: 2026-03-16
|
||||
|
||||
ALTER TABLE ai_chat_conversations ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE ai_chat_conversations ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMP;
|
||||
ALTER TABLE ai_chat_conversations ADD COLUMN IF NOT EXISTS custom_name VARCHAR(255);
|
||||
|
||||
-- Index for efficient pinned conversations query
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_conversations_pinned ON ai_chat_conversations(user_id, is_pinned, updated_at DESC);
|
||||
|
||||
-- Grants
|
||||
GRANT ALL ON TABLE ai_chat_conversations TO nordabiz_app;
|
||||
@ -145,8 +145,19 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conversation-delete {
|
||||
.conversation-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
flex-shrink: 0;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.conversation-item:hover .conversation-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.conversation-action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
@ -154,15 +165,77 @@
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.conversation-item:hover .conversation-delete {
|
||||
.conversation-action-btn:hover {
|
||||
color: #6b7280;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.conversation-action-btn.pin-btn:hover {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
.conversation-action-btn.delete-btn:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.conversation-pin-icon {
|
||||
color: #f59e0b;
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.conversations-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: var(--spacing-sm) var(--spacing-md) 4px;
|
||||
}
|
||||
|
||||
/* Rename inline input */
|
||||
.conversation-rename-input {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-sm);
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
outline: none;
|
||||
background: white;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-rename-input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
/* Keep actions visible for pinned items */
|
||||
.conversation-item.pinned .conversation-actions .pin-btn {
|
||||
opacity: 1;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.conversation-item.pinned .conversation-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.conversation-delete:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
.conversation-item.pinned .conversation-actions .delete-btn,
|
||||
.conversation-item.pinned .conversation-actions .rename-btn {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.conversation-item.pinned:hover .conversation-actions .delete-btn,
|
||||
.conversation-item.pinned:hover .conversation-actions .rename-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-empty {
|
||||
@ -1735,22 +1808,145 @@ function renderConversationsList() {
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = conversations.map(conv => `
|
||||
<div class="conversation-item ${conv.id === currentConversationId ? 'active' : ''}"
|
||||
const pinned = conversations.filter(c => c.is_pinned);
|
||||
const unpinned = conversations.filter(c => !c.is_pinned);
|
||||
|
||||
let html = '';
|
||||
|
||||
if (pinned.length > 0) {
|
||||
html += '<div class="conversations-section-title">Przypięte</div>';
|
||||
html += pinned.map(conv => renderConversationItem(conv, true)).join('');
|
||||
}
|
||||
|
||||
if (pinned.length > 0 && unpinned.length > 0) {
|
||||
html += '<div class="conversations-section-title">Historia</div>';
|
||||
}
|
||||
|
||||
html += unpinned.map(conv => renderConversationItem(conv, false)).join('');
|
||||
|
||||
list.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderConversationItem(conv, isPinned) {
|
||||
return `
|
||||
<div class="conversation-item ${conv.id === currentConversationId ? 'active' : ''} ${isPinned ? 'pinned' : ''}"
|
||||
onclick="loadConversation(${conv.id})"
|
||||
data-id="${conv.id}">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||
</svg>
|
||||
${isPinned ? '<svg class="conversation-pin-icon" fill="currentColor" viewBox="0 0 20 20"><path d="M5 5a2 2 0 012-2h6a2 2 0 012 2v2a2 2 0 01-2 2H7a2 2 0 01-2-2V5zm7 8a1 1 0 00-1 1v3a1 1 0 002 0v-3a1 1 0 00-1-1zm-3 0a1 1 0 00-1 1v3a1 1 0 002 0v-3a1 1 0 00-1-1z"/><path d="M4 9h12v2H4z"/></svg>' : '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>'}
|
||||
<span class="conversation-title">${escapeHtml(conv.title)}</span>
|
||||
<button class="conversation-delete" onclick="event.stopPropagation(); deleteConversation(${conv.id})" title="Usuń">
|
||||
<div class="conversation-actions">
|
||||
<button class="conversation-action-btn pin-btn" onclick="event.stopPropagation(); togglePin(${conv.id})" title="${isPinned ? 'Odepnij' : 'Przypnij'}">
|
||||
<svg fill="${isPinned ? 'currentColor' : 'none'}" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v3a2 2 0 01-1 1.73V15l-2 2v4l-2-2H10l-2 2v-4l-2-2V9.73A2 2 0 015 8V5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="conversation-action-btn rename-btn" onclick="event.stopPropagation(); startRename(${conv.id})" title="Zmień nazwę">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="conversation-action-btn delete-btn" onclick="event.stopPropagation(); deleteConversation(${conv.id})" title="Usuń">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Toggle pin/unpin conversation
|
||||
async function togglePin(conversationId) {
|
||||
try {
|
||||
const response = await fetch(`/api/chat/${conversationId}/pin`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Update local state
|
||||
const conv = conversations.find(c => c.id === conversationId);
|
||||
if (conv) conv.is_pinned = data.is_pinned;
|
||||
// Re-sort: pinned first, then by updated_at
|
||||
conversations.sort((a, b) => {
|
||||
if (a.is_pinned !== b.is_pinned) return b.is_pinned ? 1 : -1;
|
||||
return new Date(b.updated_at) - new Date(a.updated_at);
|
||||
});
|
||||
renderConversationsList();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling pin:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Start inline rename
|
||||
function startRename(conversationId) {
|
||||
const item = document.querySelector(`.conversation-item[data-id="${conversationId}"]`);
|
||||
if (!item) return;
|
||||
|
||||
const titleSpan = item.querySelector('.conversation-title');
|
||||
const currentTitle = titleSpan.textContent;
|
||||
|
||||
// Replace title with input
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'conversation-rename-input';
|
||||
input.value = currentTitle;
|
||||
input.maxLength = 255;
|
||||
|
||||
titleSpan.replaceWith(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
// Prevent click from loading conversation
|
||||
const originalOnclick = item.onclick;
|
||||
item.onclick = null;
|
||||
|
||||
function finishRename(save) {
|
||||
const newName = input.value.trim();
|
||||
|
||||
if (save && newName && newName !== currentTitle) {
|
||||
saveRename(conversationId, newName);
|
||||
}
|
||||
|
||||
// Restore title span
|
||||
const span = document.createElement('span');
|
||||
span.className = 'conversation-title';
|
||||
span.textContent = save && newName ? newName : currentTitle;
|
||||
input.replaceWith(span);
|
||||
item.onclick = originalOnclick;
|
||||
}
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); finishRename(true); }
|
||||
if (e.key === 'Escape') { finishRename(false); }
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => finishRename(true));
|
||||
}
|
||||
|
||||
// Save renamed conversation
|
||||
async function saveRename(conversationId, name) {
|
||||
try {
|
||||
const response = await fetch(`/api/chat/${conversationId}/rename`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Update local state
|
||||
const conv = conversations.find(c => c.id === conversationId);
|
||||
if (conv) {
|
||||
conv.custom_name = data.name;
|
||||
conv.title = data.name;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error renaming conversation:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load a specific conversation
|
||||
|
||||
Loading…
Reference in New Issue
Block a user