Add edit/delete history, named sessions, scroll fix, systemd service
- Edit/delete individual messages from session context with inline editing
(Ctrl+Enter saves, Escape cancels); changes sync to backend via PUT /history
- PUT /history/{session_id} endpoint to replace full message list
- Named sessions: readable slugs (e.g. quiet-spring) instead of UUID fragments
- Scroll no longer snaps to bottom when user has scrolled up to read history
- cortex.service: systemd unit for auto-start and restart-on-failure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -419,12 +419,109 @@
|
||||
#send:disabled { background: var(--surface); color: var(--muted);
|
||||
border-color: var(--border); cursor: not-allowed; }
|
||||
|
||||
/* Stop button */
|
||||
#stop {
|
||||
display: none;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
border-radius: 8px;
|
||||
padding: 10px 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
#stop:hover { background: #5c1a1a; }
|
||||
|
||||
#session-id {
|
||||
font-size: 0.7rem;
|
||||
color: var(--border);
|
||||
padding: 0 20px 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* ── Message wrappers (edit/delete controls) ──────────────── */
|
||||
.msg-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 75%;
|
||||
}
|
||||
|
||||
.msg-wrapper.user { align-self: flex-end; }
|
||||
.msg-wrapper.assistant { align-self: flex-start; }
|
||||
|
||||
/* Inner message fills wrapper width */
|
||||
.msg-wrapper .message.user,
|
||||
.msg-wrapper .message.assistant {
|
||||
align-self: stretch;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.msg-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
padding: 2px 2px 0;
|
||||
}
|
||||
|
||||
.msg-wrapper.user .msg-actions { justify-content: flex-end; }
|
||||
.msg-wrapper.assistant .msg-actions { justify-content: flex-start; }
|
||||
.msg-wrapper:hover .msg-actions { opacity: 1; }
|
||||
|
||||
.msg-act-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 6px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.msg-act-btn:hover { color: var(--text); border-color: var(--muted); }
|
||||
.msg-act-btn.del:hover { color: var(--error-text); border-color: var(--error-border); }
|
||||
|
||||
/* Inline edit */
|
||||
.edit-textarea {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--muted);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
padding: 6px 10px;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.edit-textarea:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
.edit-btns {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.edit-save-btn, .edit-cancel-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
padding: 3px 10px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.edit-save-btn { border-color: var(--inara-border); color: var(--accent); }
|
||||
.edit-save-btn:hover { background: var(--inara-bg); }
|
||||
.edit-cancel-btn:hover { color: var(--text); border-color: var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -460,6 +557,7 @@
|
||||
<button id="note-type-btn">private</button>
|
||||
<button id="note-btn">Note</button>
|
||||
<button id="send">Send</button>
|
||||
<button id="stop">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -477,9 +575,12 @@
|
||||
const enterToggle = document.getElementById('enter-toggle');
|
||||
const noteTypeBtnEl = document.getElementById('note-type-btn');
|
||||
const noteBtnEl = document.getElementById('note-btn');
|
||||
const stopBtn = document.getElementById('stop');
|
||||
|
||||
let sessionId = null;
|
||||
let primaryBackend = 'claude';
|
||||
let sessionId = null;
|
||||
let primaryBackend = 'claude';
|
||||
let activeController = null;
|
||||
let currentHistory = []; // mirrors backend session [{role, content}, ...]
|
||||
|
||||
// ── Enter toggle ─────────────────────────────────────────────
|
||||
// Default: Ctrl+Enter sends. Stored in localStorage.
|
||||
@@ -621,6 +722,7 @@
|
||||
const newItem = makeItem('new', '+ New session', '');
|
||||
newItem.addEventListener('click', () => {
|
||||
sessionId = null;
|
||||
currentHistory = [];
|
||||
messagesEl.innerHTML = '';
|
||||
sessionEl.textContent = '';
|
||||
addMessage('system', 'New session');
|
||||
@@ -673,12 +775,18 @@
|
||||
messagesEl.innerHTML = '';
|
||||
sessionId = id;
|
||||
sessionEl.textContent = `session: ${id}`;
|
||||
currentHistory = [];
|
||||
|
||||
for (const msg of data.messages) {
|
||||
addMessage(msg.role === 'user' ? 'user' : 'assistant', msg.content);
|
||||
for (let i = 0; i < data.messages.length; i++) {
|
||||
const msg = data.messages[i];
|
||||
const role = msg.role === 'user' ? 'user' : 'assistant';
|
||||
currentHistory.push({ role, content: msg.content });
|
||||
const msgDiv = addMessage(role, msg.content);
|
||||
attachHistoryControls(msgDiv, i);
|
||||
}
|
||||
|
||||
addMessage('system', `Resumed session ${id}`);
|
||||
scrollToBottom();
|
||||
sessionsPanel.classList.remove('open');
|
||||
inputEl.focus();
|
||||
}
|
||||
@@ -703,8 +811,20 @@
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
// ── Scroll helpers ────────────────────────────────────────────
|
||||
// Only auto-scroll when the user is already near the bottom (within 80px).
|
||||
// Explicit user actions (send, resume) call scrollToBottom() directly.
|
||||
function isNearBottom() {
|
||||
return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 80;
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
|
||||
// ── Chat ─────────────────────────────────────────────────────
|
||||
|
||||
// Returns the inner .message div. For user/assistant, wraps in .msg-wrapper.
|
||||
function addMessage(role, text) {
|
||||
const div = document.createElement('div');
|
||||
div.className = `message ${role}`;
|
||||
@@ -730,11 +850,152 @@
|
||||
div.textContent = text;
|
||||
}
|
||||
|
||||
messagesEl.appendChild(div);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
// Wrap user/assistant messages so action buttons can be attached
|
||||
const baseRole = role.split(' ')[0]; // 'user' or 'assistant' (strips 'thinking' etc)
|
||||
if (baseRole === 'user' || baseRole === 'assistant') {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = `msg-wrapper ${baseRole}`;
|
||||
wrapper.appendChild(div);
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'msg-actions';
|
||||
wrapper.appendChild(actions);
|
||||
messagesEl.appendChild(wrapper);
|
||||
} else {
|
||||
messagesEl.appendChild(div);
|
||||
}
|
||||
|
||||
if (isNearBottom()) scrollToBottom();
|
||||
return div;
|
||||
}
|
||||
|
||||
// Wire edit/delete controls onto a message div (must already be in a .msg-wrapper).
|
||||
// histIdx is the index into currentHistory. Reads wrapper.dataset.histIdx at click time
|
||||
// so re-indexing after deletions is automatically picked up.
|
||||
function attachHistoryControls(msgDiv, histIdx) {
|
||||
const wrapper = msgDiv.parentElement;
|
||||
if (!wrapper || !wrapper.classList.contains('msg-wrapper')) return;
|
||||
wrapper.dataset.histIdx = histIdx;
|
||||
|
||||
const actionsDiv = wrapper.querySelector('.msg-actions');
|
||||
if (!actionsDiv) return;
|
||||
actionsDiv.innerHTML = '';
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'msg-act-btn';
|
||||
editBtn.textContent = 'edit';
|
||||
editBtn.addEventListener('click', () => {
|
||||
startEdit(msgDiv);
|
||||
});
|
||||
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'msg-act-btn del';
|
||||
delBtn.textContent = 'del';
|
||||
delBtn.addEventListener('click', () => {
|
||||
deleteMsg(wrapper);
|
||||
});
|
||||
|
||||
actionsDiv.appendChild(editBtn);
|
||||
actionsDiv.appendChild(delBtn);
|
||||
}
|
||||
|
||||
// After any currentHistory splice, renumber all wrapper data-hist-idx attributes.
|
||||
function reIndexWrappers() {
|
||||
messagesEl.querySelectorAll('.msg-wrapper').forEach((w, i) => {
|
||||
w.dataset.histIdx = i;
|
||||
});
|
||||
}
|
||||
|
||||
function startEdit(msgDiv) {
|
||||
const wrapper = msgDiv.parentElement;
|
||||
const idx = parseInt(wrapper.dataset.histIdx);
|
||||
const role = msgDiv.classList.contains('user') ? 'user' : 'assistant';
|
||||
const originalText = currentHistory[idx]?.content
|
||||
|| msgDiv.dataset.raw
|
||||
|| msgDiv.textContent;
|
||||
|
||||
// Lock the current rendered size so the bubble doesn't collapse when we clear it
|
||||
const lockedW = msgDiv.offsetWidth;
|
||||
const lockedH = msgDiv.offsetHeight;
|
||||
msgDiv.style.minWidth = lockedW + 'px';
|
||||
msgDiv.style.minHeight = lockedH + 'px';
|
||||
|
||||
const actionsDiv = wrapper.querySelector('.msg-actions');
|
||||
if (actionsDiv) actionsDiv.style.display = 'none';
|
||||
|
||||
const ta = document.createElement('textarea');
|
||||
ta.className = 'edit-textarea';
|
||||
ta.value = originalText;
|
||||
ta.rows = Math.min(originalText.split('\n').length + 1, 12);
|
||||
|
||||
const saveBtn = document.createElement('button');
|
||||
saveBtn.textContent = 'Save';
|
||||
saveBtn.className = 'edit-save-btn';
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
cancelBtn.className = 'edit-cancel-btn';
|
||||
|
||||
const btnRow = document.createElement('div');
|
||||
btnRow.className = 'edit-btns';
|
||||
btnRow.appendChild(saveBtn);
|
||||
btnRow.appendChild(cancelBtn);
|
||||
|
||||
msgDiv.innerHTML = '';
|
||||
msgDiv.appendChild(ta);
|
||||
msgDiv.appendChild(btnRow);
|
||||
ta.focus();
|
||||
ta.setSelectionRange(ta.value.length, ta.value.length);
|
||||
|
||||
function unlock() {
|
||||
msgDiv.style.minWidth = '';
|
||||
msgDiv.style.minHeight = '';
|
||||
if (actionsDiv) actionsDiv.style.display = '';
|
||||
}
|
||||
|
||||
function restore() {
|
||||
setMessageText(msgDiv, role, originalText);
|
||||
unlock();
|
||||
}
|
||||
|
||||
function save() {
|
||||
const newText = ta.value.trim();
|
||||
if (!newText) return;
|
||||
const currentIdx = parseInt(wrapper.dataset.histIdx);
|
||||
currentHistory[currentIdx].content = newText;
|
||||
setMessageText(msgDiv, role, newText);
|
||||
unlock();
|
||||
syncHistory();
|
||||
}
|
||||
|
||||
saveBtn.addEventListener('click', save);
|
||||
cancelBtn.addEventListener('click', restore);
|
||||
ta.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); restore(); }
|
||||
});
|
||||
}
|
||||
|
||||
function deleteMsg(wrapper) {
|
||||
const idx = parseInt(wrapper.dataset.histIdx);
|
||||
currentHistory.splice(idx, 1);
|
||||
wrapper.remove();
|
||||
reIndexWrappers();
|
||||
syncHistory();
|
||||
}
|
||||
|
||||
async function syncHistory() {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
await fetch(`/history/${sessionId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: currentHistory }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('syncHistory failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function setMessageText(div, role, text) {
|
||||
if (role === 'assistant' && typeof marked !== 'undefined') {
|
||||
div.dataset.raw = text;
|
||||
@@ -803,16 +1064,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
stopBtn.addEventListener('click', () => {
|
||||
if (activeController) activeController.abort();
|
||||
});
|
||||
|
||||
async function sendMessage() {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text || sendBtn.disabled) return;
|
||||
if (!text || activeController) return;
|
||||
|
||||
inputEl.value = '';
|
||||
syncHeight();
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.style.display = 'none';
|
||||
stopBtn.style.display = 'block';
|
||||
headerEmoji.classList.add('processing');
|
||||
|
||||
addMessage('user', text);
|
||||
activeController = new AbortController();
|
||||
|
||||
const userHistIdx = currentHistory.length;
|
||||
currentHistory.push({ role: 'user', content: text });
|
||||
const userMsgDiv = addMessage('user', text);
|
||||
attachHistoryControls(userMsgDiv, userHistIdx);
|
||||
scrollToBottom();
|
||||
|
||||
const thinkingDiv = addMessage('assistant thinking', '✨ thinking…');
|
||||
|
||||
try {
|
||||
@@ -820,6 +1093,7 @@
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: text, session_id: sessionId }),
|
||||
signal: activeController.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
@@ -847,6 +1121,9 @@
|
||||
sessionEl.textContent = `session: ${sessionId}`;
|
||||
thinkingDiv.className = 'message assistant';
|
||||
setMessageText(thinkingDiv, 'assistant', data.response);
|
||||
const assistHistIdx = currentHistory.length;
|
||||
currentHistory.push({ role: 'assistant', content: data.response });
|
||||
attachHistoryControls(thinkingDiv, assistHistIdx);
|
||||
if (data.fallback_used) {
|
||||
addMessage('system',
|
||||
`⚡ ${primaryBackend} unavailable — answered by ${data.backend}`);
|
||||
@@ -857,12 +1134,19 @@
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
thinkingDiv.className = 'message error';
|
||||
thinkingDiv.textContent = `Error: ${err.message}`;
|
||||
if (err.name === 'AbortError') {
|
||||
thinkingDiv.className = 'message system';
|
||||
thinkingDiv.textContent = 'Stopped.';
|
||||
} else {
|
||||
thinkingDiv.className = 'message error';
|
||||
thinkingDiv.textContent = `Error: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
activeController = null;
|
||||
headerEmoji.classList.remove('processing');
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.style.display = 'block';
|
||||
stopBtn.style.display = 'none';
|
||||
inputEl.focus();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user