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

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:
Maciej Pienczyn 2026-03-16 22:24:17 +01:00
parent 41f5b688a8
commit 6807506913
4 changed files with 302 additions and 19 deletions

View File

@ -279,14 +279,20 @@ def chat_list_conversations():
try: try:
conversations = db.query(AIChatConversation).filter_by( conversations = db.query(AIChatConversation).filter_by(
user_id=current_user.id 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({ return jsonify({
'success': True, 'success': True,
'conversations': [ 'conversations': [
{ {
'id': c.id, '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, 'created_at': c.started_at.isoformat() if c.started_at else None,
'updated_at': c.updated_at.isoformat() if c.updated_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 'message_count': len(c.messages) if c.messages else 0
@ -301,6 +307,70 @@ def chat_list_conversations():
db.close() 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']) @bp.route('/api/chat/<int:conversation_id>/delete', methods=['DELETE'])
@login_required @login_required
@member_required @member_required

View File

@ -1518,6 +1518,11 @@ class AIChatConversation(Base):
message_count = Column(Integer, default=0) message_count = Column(Integer, default=0)
model_name = Column(String(100)) model_name = Column(String(100))
# Pin & custom name
is_pinned = Column(Boolean, default=False)
pinned_at = Column(DateTime)
custom_name = Column(String(255))
# Relationships # Relationships
user = relationship('User', back_populates='conversations') user = relationship('User', back_populates='conversations')
messages = relationship('AIChatMessage', back_populates='conversation', cascade='all, delete-orphan', order_by='AIChatMessage.created_at') messages = relationship('AIChatMessage', back_populates='conversation', cascade='all, delete-orphan', order_by='AIChatMessage.created_at')

View 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;

View File

@ -145,8 +145,19 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.conversation-delete { .conversation-actions {
display: flex;
gap: 2px;
opacity: 0; opacity: 0;
flex-shrink: 0;
transition: var(--transition);
}
.conversation-item:hover .conversation-actions {
opacity: 1;
}
.conversation-action-btn {
background: none; background: none;
border: none; border: none;
color: #9ca3af; color: #9ca3af;
@ -154,15 +165,77 @@
padding: 4px; padding: 4px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
transition: var(--transition); 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; opacity: 1;
} }
.conversation-delete:hover { .conversation-item.pinned .conversation-actions .delete-btn,
color: #ef4444; .conversation-item.pinned .conversation-actions .rename-btn {
background: rgba(239, 68, 68, 0.1); opacity: 0;
}
.conversation-item.pinned:hover .conversation-actions .delete-btn,
.conversation-item.pinned:hover .conversation-actions .rename-btn {
opacity: 1;
} }
.sidebar-empty { .sidebar-empty {
@ -1735,22 +1808,145 @@ function renderConversationsList() {
return; return;
} }
list.innerHTML = conversations.map(conv => ` const pinned = conversations.filter(c => c.is_pinned);
<div class="conversation-item ${conv.id === currentConversationId ? 'active' : ''}" 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})" onclick="loadConversation(${conv.id})"
data-id="${conv.id}"> data-id="${conv.id}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> ${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>'}
<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> <span class="conversation-title">${escapeHtml(conv.title)}</span>
<button class="conversation-delete" onclick="event.stopPropagation(); deleteConversation(${conv.id})" title="Usuń"> <div class="conversation-actions">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14"> <button class="conversation-action-btn pin-btn" onclick="event.stopPropagation(); togglePin(${conv.id})" title="${isPinned ? 'Odepnij' : 'Przypnij'}">
<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 fill="${isPinned ? 'currentColor' : 'none'}" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
</svg> <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"/>
</button> </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>
</div> </div>
`).join(''); `;
}
// 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 // Load a specific conversation