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:
|
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
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
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;
|
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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user