Christian Kniep
add settings and UI/UX improvements
2e18bf2
{% extends "base.html" %}
{% block title %}{{ contact.contact_name }} - PrepMate{% endblock %}
{% block head %}
<!-- Marked.js for Markdown rendering -->
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
<!-- Message Cache for Optimistic UI Updates (Feature: 012-realtime-message-display) -->
<script src="{{ url_for('static', filename='js/message-cache.js') }}"></script>
<style>
/* Markdown styling for assistant messages */
.markdown-content {
line-height: 1.6;
}
.markdown-content p {
margin-bottom: 0.75rem;
}
.markdown-content p:last-child {
margin-bottom: 0;
}
.markdown-content ul, .markdown-content ol {
margin-bottom: 0.75rem;
padding-left: 1.5rem;
}
.markdown-content code {
background-color: rgba(0,0,0,0.05);
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.9em;
}
.markdown-content pre {
background-color: rgba(0,0,0,0.05);
padding: 0.75rem;
border-radius: 0.25rem;
overflow-x: auto;
margin-bottom: 0.75rem;
}
.markdown-content pre code {
background-color: transparent;
padding: 0;
}
.markdown-content blockquote {
border-left: 3px solid #dee2e6;
padding-left: 1rem;
margin-left: 0;
color: #6c757d;
}
.markdown-content h1, .markdown-content h2, .markdown-content h3,
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
margin-top: 1rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.markdown-content h1 { font-size: 1.5rem; }
.markdown-content h2 { font-size: 1.3rem; }
.markdown-content h3 { font-size: 1.1rem; }
</style>
{% endblock %}
{% block content %}
<div class="container mt-4">
<!-- Back Button and Header -->
<div class="row mb-3">
<div class="col">
<a href="{{ url_for('contacts.list_contacts') }}" class="btn btn-sm btn-outline-secondary mb-2">
<i class="bi bi-arrow-left"></i> Back to Contacts
</a>
</div>
</div>
<!-- Contact Header -->
<div class="row mb-4">
<div class="col-md-8">
<div class="d-flex align-items-start">
<i class="bi bi-person-circle text-primary me-3" style="font-size: 3rem;"></i>
<div class="flex-grow-1">
<h1 class="h3 mb-1">{{ contact.contact_name }}</h1>
{% if contact.contact_description %}
<p class="text-muted mb-2">{{ contact.contact_description }}</p>
{% endif %}
<small class="text-muted">
<i class="bi bi-clock"></i>
Last interaction: {{ contact.last_interaction.strftime('%Y-%m-%d %H:%M') }}
</small>
</div>
<div class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#editContactModal">
<i class="bi bi-pencil"></i> Edit Contact
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item text-danger" href="#" data-bs-toggle="modal" data-bs-target="#deleteContactModal">
<i class="bi bi-trash"></i> Delete Contact
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- How it Works Info Box -->
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="h6 mb-0">
<i class="bi bi-info-circle"></i> How it Works
</h3>
</div>
<div class="card-body">
<p class="small mb-1">
<strong>Add Fact:</strong> Store information about {{ contact.contact_name }} (max 2,000 chars). Facts help build context for future conversations.
</p>
<p class="small mb-0">
<strong>Ask Question:</strong> Get AI responses using both your profile facts and {{ contact.contact_name }}'s facts as context.
</p>
</div>
</div>
</div>
</div>
<!-- Contact Session Interface -->
<div class="row">
<!-- Full Width Conversation Area -->
<div class="col-12">
<div class="card" style="height: 600px; display: flex; flex-direction: column;">
<div class="card-header bg-primary text-white">
<h2 class="h6 mb-0">
<i class="bi bi-chat-dots"></i> Conversation with {{ contact.contact_name }}
</h2>
<small class="text-white-50">All messages and facts about this contact</small>
</div>
<!-- Message History (All Messages: Facts + Chat) -->
<div class="card-body flex-grow-1" style="overflow-y: auto;" id="messageHistory">
{% set all_messages = facts + messages %}
{% if all_messages %}
{% for message in all_messages | sort(attribute='created_at') %}
<div class="mb-3">
{% if message.get('mode') == 'memorize' %}
<!-- Fact Message -->
<div class="p-3 rounded bg-light border-start border-info border-3">
<div class="d-flex align-items-start">
<i class="bi bi-bookmark-fill text-info me-2"></i>
<div class="flex-grow-1">
<p class="mb-1">{{ message.get('content', message.get('message', '')) }}</p>
<small class="text-muted">
{{ message.get('created_at', '') }}
</small>
</div>
</div>
</div>
{% else %}
<!-- Chat Message - Different styling for user vs assistant -->
{% if message.get('sender') == 'assistant' %}
<!-- Assistant Message (AI response) - Right aligned, gray background -->
<div class="d-flex justify-content-end">
<div class="bg-light border p-3 rounded" style="max-width: 75%;">
<div class="markdown-content text-dark" data-markdown-content>{{ message.get('content', message.get('message', '')) }}</div>
<small class="text-muted">
<i class="bi bi-cpu"></i> AI · {{ message.get('created_at', '') }}
</small>
</div>
</div>
{% else %}
<!-- User Message - Left aligned, blue background -->
<div class="d-flex justify-content-start">
<div class="bg-primary text-white p-3 rounded" style="max-width: 75%;">
<p class="mb-1">{{ message.get('content', message.get('message', '')) }}</p>
<small class="text-white-50">
{{ message.get('created_at', '') }}
</small>
</div>
</div>
{% endif %}
{% endif %}
</div>
{% endfor %}
{% else %}
<!-- Feature: 001-contact-session-fixes - Empty state placeholder -->
<div class="h-100 d-flex flex-column justify-content-center align-items-center p-4">
<div class="alert alert-info" role="status" style="max-width: 500px;">
<i class="bi bi-info-circle me-2"></i>
<strong>No facts or messages yet.</strong>
<p class="mb-0 mt-2 small">Add your first fact about {{ contact.contact_name }} using the "Add Fact" button below, or ask a question to start a conversation.</p>
</div>
</div>
{% endif %}
</div>
<!-- Message/Fact Input -->
<div class="card-footer bg-light">
<!-- Error Messages (T016: Feature 001-refine-memory-producer-logic) -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show mb-2" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" id="chatForm" onsubmit="return false;">
<div class="mb-2">
<textarea class="form-control"
id="messageContent"
name="content"
rows="2"
maxlength="10000"
placeholder="Type a message or fact about {{ contact.contact_name }}..."
required
style="resize: none;">{{ preserved_content or '' }}</textarea>
<small class="text-muted" id="charCounter">{{ (preserved_content|length) if preserved_content else 0 }} / 10000</small>
</div>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">
<i class="bi bi-info-circle"></i>
Questions use your profile as context
</small>
<div>
<button type="button" class="btn btn-outline-secondary me-2" id="addFactBtn">
<i class="bi bi-bookmark-plus" id="factIcon"></i>
<span id="factText">Add Fact</span>
<span class="spinner-border spinner-border-sm d-none" id="factSpinner" role="status" aria-hidden="true"></span>
</button>
<button type="button" class="btn btn-primary" id="askBtn">
<i class="bi bi-send-fill" id="askIcon"></i>
<span id="askText">Ask Question</span>
<span class="spinner-border spinner-border-sm d-none" id="askSpinner" role="status" aria-hidden="true"></span>
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Edit Contact Modal -->
<div class="modal fade" id="editContactModal" tabindex="-1" aria-labelledby="editContactModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('contacts.update_contact', session_id=contact.session_id) }}" id="editContactForm">
<input type="hidden" name="_method" value="PUT">
<div class="modal-header">
<h5 class="modal-title" id="editContactModalLabel">
<i class="bi bi-pencil"></i> Edit Contact
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="editContactName" class="form-label">
Contact Name <span class="text-danger">*</span>
</label>
<input type="text"
class="form-control"
id="editContactName"
name="contact_name"
maxlength="255"
value="{{ contact.contact_name }}"
required
autocomplete="off">
<div class="form-text">
<span id="editNameCounter">0 / 255</span> characters
</div>
</div>
<div class="mb-3">
<label for="editContactDescription" class="form-label">
Description (Optional)
</label>
<textarea class="form-control"
id="editContactDescription"
name="contact_description"
rows="3"
maxlength="500">{{ contact.contact_description or '' }}</textarea>
<div class="form-text">
<span id="editDescCounter">0 / 500</span> characters
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Contact Modal -->
<div class="modal fade" id="deleteContactModal" tabindex="-1" aria-labelledby="deleteContactModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('contacts.delete_contact', session_id=contact.session_id) }}">
<div class="modal-header">
<h5 class="modal-title" id="deleteContactModalLabel">
<i class="bi bi-exclamation-triangle text-danger"></i> Delete Contact
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ contact.contact_name }}</strong>?</p>
<div class="alert alert-warning" role="alert">
<i class="bi bi-exclamation-triangle"></i>
This will remove the contact from your list but will <strong>not</strong> delete messages or facts from the backend.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> Delete Contact
</button>
</div>
</form>
</div>
</div>
</div>
<script>
// Character counters for edit form
const editContactName = document.getElementById('editContactName');
const editContactDescription = document.getElementById('editContactDescription');
const editNameCounter = document.getElementById('editNameCounter');
const editDescCounter = document.getElementById('editDescCounter');
// Initialize counters on page load
if (editContactName) {
editNameCounter.textContent = editContactName.value.length + ' / 255';
editContactName.addEventListener('input', function() {
editNameCounter.textContent = this.value.length + ' / 255';
if (this.value.length > 240) {
editNameCounter.classList.add('text-warning');
} else {
editNameCounter.classList.remove('text-warning');
}
});
}
if (editContactDescription) {
editDescCounter.textContent = editContactDescription.value.length + ' / 500';
editContactDescription.addEventListener('input', function() {
editDescCounter.textContent = this.value.length + ' / 500';
if (this.value.length > 450) {
editDescCounter.classList.add('text-warning');
} else {
editDescCounter.classList.remove('text-warning');
}
});
}
// Form validation for edit
const editContactForm = document.getElementById('editContactForm');
if (editContactForm) {
editContactForm.addEventListener('submit', function(e) {
const name = editContactName.value.trim();
if (!name) {
e.preventDefault();
alert('Contact name is required.');
editContactName.focus();
return false;
}
if (name.length > 255) {
e.preventDefault();
alert('Contact name cannot exceed 255 characters.');
editContactName.focus();
return false;
}
const desc = editContactDescription.value.trim();
if (desc.length > 500) {
e.preventDefault();
alert('Description cannot exceed 500 characters.');
editContactDescription.focus();
return false;
}
return true;
});
}
// Render Markdown in assistant messages
document.addEventListener('DOMContentLoaded', function() {
const markdownElements = document.querySelectorAll('[data-markdown-content]');
markdownElements.forEach(function(element) {
const rawContent = element.textContent;
const renderedMarkdown = marked.parse(rawContent);
element.innerHTML = renderedMarkdown;
});
});
// T029: Initialize message cache for contacts session (Feature: 012-realtime-message-display)
const SESSION_ID = '{{ contact.session_id }}';
messageCache.init(SESSION_ID);
// Declare variables at top level
let messageHistory;
let messageContent;
let charCounter;
let chatForm;
let askBtn;
let addFactBtn;
// Initialize all elements after DOM is ready
function initializeElements() {
messageHistory = document.getElementById('messageHistory');
messageContent = document.getElementById('messageContent');
charCounter = document.getElementById('charCounter');
chatForm = document.getElementById('chatForm');
askBtn = document.getElementById('askBtn');
addFactBtn = document.getElementById('addFactBtn');
// Auto-scroll to bottom of message history
if (messageHistory) {
messageHistory.scrollTop = messageHistory.scrollHeight;
}
setupEventHandlers();
}
function setupEventHandlers() {
// Character counter with dynamic max length
if (messageContent && charCounter) {
messageContent.addEventListener('input', function() {
const length = this.value.length;
charCounter.textContent = length + ' / 10000';
if (length > 9000) {
charCounter.classList.add('text-warning');
} else {
charCounter.classList.remove('text-warning');
}
});
}
// Auto-resize message textarea
if (messageContent) {
messageContent.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
// Handle Enter key (submit question) vs Shift+Enter (new line)
messageContent.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (this.value.trim().length > 0) {
askBtn.click();
}
}
});
}
// T030-T032: Ask Question handler with optimistic updates (Feature: 012-realtime-message-display)
if (askBtn) {
askBtn.addEventListener('click', async function(e) {
e.preventDefault();
const content = messageContent.value.trim();
if (!content) {
alert('Please enter a message.');
return;
}
if (content.length > 10000) {
alert('Message cannot exceed 10,000 characters.');
return;
}
if (askBtn.disabled) {
return;
}
// Show loading state
askBtn.disabled = true;
addFactBtn.disabled = true;
document.getElementById('askIcon').classList.add('d-none');
document.getElementById('askSpinner').classList.remove('d-none');
document.getElementById('askText').textContent = 'Sending...';
try {
// T030: Optimistic update - question appears instantly
await messageCache.addOptimisticMessage(content, 'user', 'question');
// Clear textarea
messageContent.value = '';
charCounter.textContent = '0 / 10000';
messageContent.style.height = 'auto';
} catch (error) {
console.error('Error sending question:', error);
alert('Failed to send question. Please try again.');
} finally {
// Re-enable buttons
askBtn.disabled = false;
addFactBtn.disabled = false;
document.getElementById('askIcon').classList.remove('d-none');
document.getElementById('askSpinner').classList.add('d-none');
document.getElementById('askText').textContent = 'Ask Question';
}
});
}
// T034: Add Fact handler with optimistic updates (Feature: 012-realtime-message-display)
if (addFactBtn) {
addFactBtn.addEventListener('click', async function(e) {
e.preventDefault();
const content = messageContent.value.trim();
if (!content) {
alert('Please enter a fact.');
return;
}
if (content.length > 2000) {
alert('Fact cannot exceed 2,000 characters.');
return;
}
if (addFactBtn.disabled) {
return;
}
// Show loading state
askBtn.disabled = true;
addFactBtn.disabled = true;
document.getElementById('factIcon').classList.add('d-none');
document.getElementById('factSpinner').classList.remove('d-none');
document.getElementById('factText').textContent = 'Adding...';
try {
// T034: Optimistic update - fact appears instantly
await messageCache.addOptimisticMessage(content, 'user', 'fact');
// Clear textarea
messageContent.value = '';
charCounter.textContent = '0 / 10000';
messageContent.style.height = 'auto';
} catch (error) {
console.error('Error adding fact:', error);
alert('Failed to add fact. Please try again.');
} finally {
// Re-enable buttons
askBtn.disabled = false;
addFactBtn.disabled = false;
document.getElementById('factIcon').classList.remove('d-none');
document.getElementById('factSpinner').classList.add('d-none');
document.getElementById('factText').textContent = 'Add Fact';
}
});
}
}
// T031-T032: Render messages with sync status indicators (Feature: 012-realtime-message-display)
function renderMessages() {
const messages = messageCache.getAllMessages();
const container = messageHistory;
// Clear existing content
const emptyState = container.querySelector('.alert-info');
container.innerHTML = '';
if (messages.length === 0) {
if (emptyState) {
container.appendChild(emptyState);
} else {
container.innerHTML = `
<div class="h-100 d-flex flex-column justify-content-center align-items-center p-4">
<div class="alert alert-info" role="status" style="max-width: 500px;">
<i class="bi bi-info-circle me-2"></i>
<strong>No facts or messages yet.</strong>
<p class="mb-0 mt-2 small">Add your first fact about {{ contact.contact_name }} using the "Add Fact" button below, or ask a question to start a conversation.</p>
</div>
</div>
`;
}
return;
}
// Render each message
messages.forEach(message => {
const messageDiv = document.createElement('div');
messageDiv.className = 'mb-3';
messageDiv.dataset.messageId = message.id || message.tempId;
// Add pending/failed classes
if (message.syncStatus === 'pending' || message.syncStatus === 'syncing') {
messageDiv.classList.add('message-pending');
} else if (message.syncStatus === 'failed') {
messageDiv.classList.add('message-failed');
}
const timestamp = message.timestamp
? new Date(message.timestamp).toLocaleString()
: new Date(message.clientTimestamp).toLocaleString();
// Render sync indicator
let syncIndicatorHTML = '';
if (message.syncStatus === 'pending' || message.syncStatus === 'syncing') {
syncIndicatorHTML = '<span class="sync-indicator pending ms-2" title="Syncing..." aria-label="Message syncing with server">⏳</span>';
} else if (message.syncStatus === 'failed') {
syncIndicatorHTML = `
<button class="sync-indicator failed retry-btn ms-2"
onclick="retryMessage('${message.tempId}')"
title="Sync failed. Click to retry."
aria-label="Sync failed. Click to retry sending message">
⚠️ Retry
</button>
`;
} else if (message.syncStatus === 'confirmed') {
syncIndicatorHTML = '<span class="sync-indicator confirmed ms-2" title="Synced" aria-label="Message successfully synced">✓</span>';
}
// Render based on message type
if (message.type === 'fact') {
// Fact message - info border style
messageDiv.innerHTML = `
<div class="p-3 rounded bg-light border-start border-info border-3">
<div class="d-flex align-items-start">
<i class="bi bi-bookmark-fill text-info me-2"></i>
<div class="flex-grow-1">
<p class="mb-1">${escapeHtml(message.content)}</p>
<small class="text-muted">
${timestamp}${syncIndicatorHTML}
</small>
</div>
</div>
</div>
`;
} else if (message.role === 'assistant') {
// Assistant message - right aligned, gray
const renderedMarkdown = marked.parse(message.content);
messageDiv.innerHTML = `
<div class="d-flex justify-content-end">
<div class="bg-light border p-3 rounded" style="max-width: 75%;">
<div class="markdown-content text-dark">${renderedMarkdown}</div>
<small class="text-muted">
<i class="bi bi-cpu"></i> AI · ${timestamp}${syncIndicatorHTML}
</small>
</div>
</div>
`;
} else {
// User question - left aligned, blue
messageDiv.innerHTML = `
<div class="d-flex justify-content-start">
<div class="bg-primary text-white p-3 rounded" style="max-width: 75%;">
<p class="mb-1">${escapeHtml(message.content)}</p>
<small class="text-white-50">
${timestamp}${syncIndicatorHTML}
</small>
</div>
</div>
`;
}
container.appendChild(messageDiv);
});
// Auto-scroll to bottom
container.scrollTop = container.scrollHeight;
}
// Helper: Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Retry failed message
async function retryMessage(tempId) {
try {
await messageCache.retryFailedMessage(tempId);
} catch (error) {
console.error('Error retrying message:', error);
alert('Failed to retry. Please try again.');
}
}
// T032: Listen to cache events and re-render (Feature: 012-realtime-message-display)
document.addEventListener('cache:message-added', (event) => {
console.log('Message added:', event.detail);
renderMessages();
});
document.addEventListener('cache:message-confirmed', (event) => {
console.log('Message confirmed:', event.detail);
renderMessages();
});
document.addEventListener('cache:message-failed', (event) => {
console.log('Message failed:', event.detail);
renderMessages();
});
// Initialize: Load server-rendered messages into cache on first load
function initializeContactMessages() {
const cache = messageCache.getCache();
// Only load server messages if cache is empty (first visit)
if (cache.confirmedMessages.length === 0) {
{% set all_messages = facts + messages %}
{% if all_messages %}
const serverMessages = [
{% for message in all_messages | sort(attribute='created_at') %}
{
id: '{{ message.get("message_id", "") }}',
content: {{ (message.get('content', message.get('message', '')))|tojson }},
role: {% if message.get('sender') == 'assistant' %}'assistant'{% else %}'user'{% endif %},
type: {% if message.get('mode') == 'memorize' %}'fact'{% else %}'question'{% endif %},
timestamp: '{{ message.get("created_at", "") }}',
created_at: '{{ message.get("created_at", "") }}',
syncStatus: 'confirmed'
}{% if not loop.last %},{% endif %}
{% endfor %}
];
// Add to cache
cache.confirmedMessages = serverMessages;
cache.lastFetchTimestamp = new Date().toISOString();
messageCache.saveCacheToBrowser();
{% endif %}
}
// Now render from cache
renderMessages();
}
// Wait for DOM ready and initialize everything
function initializeAll() {
initializeElements();
initializeContactMessages();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeAll);
} else {
initializeAll();
}
</script>
{% endblock %}