feat: session naming, username/persona rename, help page, contrast fixes

- Session name field: PATCH /sessions/{id} endpoint, inline rename button in UI
- Persona rename: inline ✏ toggle form in settings, POST /settings/persona/rename
- Username rename: inline form in settings, POST /settings/username (renames home dir, forces re-login)
- Help page: dedicated /help route replacing modal, collapsible sections
- Per-persona isolation: files.py and session_store.py now scope to correct user/persona
- Contrast/visibility: muted text bumped to slate-400+, session rename btn at 0.4 opacity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-23 23:10:12 -04:00
parent 1b425a539f
commit 0cf0d65e9e
10 changed files with 351 additions and 33 deletions

View File

@@ -6,7 +6,7 @@ from pydantic import BaseModel
from context_loader import load_context
from llm_client import complete
from session_logger import log_turn
from session_store import load as load_session, save as save_session, list_all, generate_session_id, delete as delete_session
from session_store import load as load_session, save as save_session, list_all, generate_session_id, delete as delete_session, rename as rename_session
from config import settings
from persona import set_context, validate as validate_persona
import event_bus
@@ -171,6 +171,24 @@ async def list_sessions(
return {"sessions": list_all()}
class SessionRename(BaseModel):
name: str
@router.patch("/sessions/{session_id}")
async def rename_session_endpoint(
session_id: str,
req: SessionRename,
user: str = Query("scott"),
persona: str = Query("inara"),
) -> dict:
_set_ctx(user, persona)
found = rename_session(session_id, req.name.strip())
if not found:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
return {"ok": True, "session_id": session_id, "name": req.name.strip()}
@router.delete("/sessions/{session_id}")
async def delete_session_endpoint(
session_id: str,

View File

@@ -2,11 +2,14 @@
Account settings router.
Routes:
GET /settings → show account settings page (requires auth)
POST /settings/password → change password
GET /settings → show account settings page (requires auth)
POST /settings/password → change password
POST /settings/username → rename the user account (forces re-login)
POST /settings/persona/rename → rename a persona directory
"""
import logging
import re
from pathlib import Path
import jwt
@@ -15,6 +18,9 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from auth_utils import COOKIE_NAME, decode_token, check_credentials, set_password
from persona import list_user_personas
from config import settings as app_settings
_SLUG_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -36,7 +42,18 @@ def _settings_page(username: str, personas: list[str], success: str = "", error:
html = (_STATIC / "settings.html").read_text()
html = html.replace("{{ username }}", username)
persona_items = "\n".join(
f'<li><a href="/{username}/{p}">{p}</a></li>' for p in personas
f'''<li>
<a href="/{username}/{p}" class="persona-link">{p}</a>
<button class="persona-rename-toggle" data-persona="{p}" title="Rename">✏</button>
<form class="persona-rename-form" data-persona="{p}"
method="POST" action="/settings/persona/rename" style="display:none">
<input type="hidden" name="old_name" value="{p}">
<input type="text" name="new_name" value="{p}"
pattern="[a-z_][a-z0-9_\\-]{{0,31}}" required>
<button type="submit">Save</button>
<button type="button" class="persona-rename-cancel">Cancel</button>
</form>
</li>''' for p in personas
)
html = html.replace("{{ persona_items }}", persona_items or "<li><em>No personas yet.</em></li>")
back_persona = personas[0] if personas else ""
@@ -82,3 +99,79 @@ async def change_password(
set_password(username, new_password)
logger.info("password changed: %s", username)
return HTMLResponse(_settings_page(username, personas, success="Password updated successfully."))
@router.post("/settings/username", include_in_schema=False)
async def rename_username(
request: Request,
new_username: str = Form(...),
):
username = _get_session_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username)
new_username = new_username.strip().lower()
if not _SLUG_RE.match(new_username):
return HTMLResponse(_settings_page(
username, personas,
error="Invalid username. Use lowercase letters, digits, _ or - only."))
if new_username == username:
return RedirectResponse("/settings", status_code=302)
home_root = app_settings.home_root()
old_dir = home_root / username
new_dir = home_root / new_username
if new_dir.exists():
return HTMLResponse(_settings_page(
username, personas,
error=f"Username '{new_username}' is already taken."))
old_dir.rename(new_dir)
logger.info("user renamed: %s%s", username, new_username)
# Clear the auth cookie — old JWT now refers to a non-existent user
resp = RedirectResponse("/login?msg=username_changed", status_code=302)
resp.delete_cookie(COOKIE_NAME)
return resp
@router.post("/settings/persona/rename", include_in_schema=False)
async def rename_persona(
request: Request,
old_name: str = Form(...),
new_name: str = Form(...),
):
username = _get_session_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username)
new_name = new_name.strip().lower()
if not _SLUG_RE.match(new_name):
return HTMLResponse(_settings_page(
username, personas,
error="Invalid name. Use lowercase letters, digits, _ or - only."))
if new_name == old_name:
return RedirectResponse("/settings", status_code=302)
persona_root = app_settings.home_root() / username / "persona"
old_dir = persona_root / old_name
new_dir = persona_root / new_name
if not old_dir.exists():
return HTMLResponse(_settings_page(username, personas, error=f"Persona '{old_name}' not found."))
if new_dir.exists():
return HTMLResponse(_settings_page(
username, personas,
error=f"A persona named '{new_name}' already exists."))
old_dir.rename(new_dir)
logger.info("persona renamed: %s/%s%s", username, old_name, new_name)
return RedirectResponse("/settings", status_code=302)

View File

@@ -62,12 +62,29 @@ def save(session_id: str, messages: list[dict]) -> None:
# Enforce rolling window
windowed = messages[-settings.max_history_messages:]
path.write_text(json.dumps({
data = {
"session_id": session_id,
"created": existing.get("created", datetime.now().isoformat()),
"updated": datetime.now().isoformat(),
"messages": windowed,
}, indent=2))
}
if "name" in existing:
data["name"] = existing["name"]
path.write_text(json.dumps(data, indent=2))
def rename(session_id: str, name: str) -> bool:
"""Set (or clear) the friendly name on a session. Returns False if not found."""
path = _path(session_id)
if not path.exists():
return False
data = json.loads(path.read_text())
if name:
data["name"] = name
else:
data.pop("name", None)
path.write_text(json.dumps(data, indent=2))
return True
def delete(session_id: str) -> bool:
@@ -87,11 +104,13 @@ def list_all() -> list[dict]:
for f in sorted(d.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
try:
data = json.loads(f.read_text())
results.append({
entry = {
"session_id": data["session_id"],
"name": data.get("name", ""),
"updated": data.get("updated"),
"message_count": len(data.get("messages", [])),
})
}
results.append(entry)
except Exception:
pass
return results

View File

@@ -235,8 +235,12 @@
}
});
// session_id → friendly name (populated on each panel render)
const sessionNames = new Map();
function renderPanel(sessions) {
sessionsPanel.innerHTML = '';
sessionNames.clear();
const newItem = makeItem('new', '+ New session', '');
newItem.addEventListener('click', () => {
@@ -259,13 +263,57 @@
}
for (const s of sessions) {
const displayName = s.name || s.session_id;
sessionNames.set(s.session_id, displayName);
const item = makeItem(
s.session_id === sessionId ? 'active' : '',
s.session_id,
displayName,
`${s.message_count} msgs · ${timeAgo(s.updated)}`
);
item.addEventListener('click', () => resumeSession(s.session_id));
// Rename button (✎)
const renameBtn = document.createElement('button');
renameBtn.className = 'session-rename-btn';
renameBtn.textContent = '✎';
renameBtn.title = 'Rename session';
renameBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const labelEl = item.querySelector('.session-id');
const current = s.name || '';
const input = document.createElement('input');
input.className = 'session-rename-input';
input.value = current;
input.placeholder = s.session_id;
labelEl.replaceWith(input);
input.focus();
input.select();
async function commitRename() {
const newName = input.value.trim();
await fetch(`/sessions/${s.session_id}?${_fileParams}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName }),
});
const res = await fetch(`/sessions?${_fileParams}`);
const data = await res.json();
renderPanel(data.sessions);
// Update status bar if this is the active session
if (sessionId === s.session_id) {
sessionEl.textContent = `session: ${newName || s.session_id}`;
}
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); commitRename(); }
if (e.key === 'Escape') { renderPanel(sessions); }
});
input.addEventListener('blur', commitRename);
});
item.appendChild(renameBtn);
const delBtn = document.createElement('button');
delBtn.className = 'session-delete-btn';
delBtn.textContent = '×';
@@ -316,7 +364,7 @@
messagesEl.innerHTML = '';
sessionId = id;
sessionEl.textContent = `session: ${id}`;
sessionEl.textContent = `session: ${sessionNames.get(id) || id}`;
currentHistory = [];
for (let i = 0; i < data.messages.length; i++) {

View File

@@ -24,7 +24,7 @@
.back-link {
display: inline-block;
font-size: 0.8rem;
color: #64748b;
color: #94a3b8;
text-decoration: none;
margin-bottom: 1.5rem;
}
@@ -36,7 +36,7 @@
border-bottom: 1px solid #2d3148;
}
header h1 { font-size: 1.5rem; font-weight: 700; color: #a78bfa; }
header p { font-size: 0.85rem; color: #64748b; margin-top: 0.25rem; }
header p { font-size: 0.85rem; color: #94a3b8; margin-top: 0.25rem; }
#help-body { line-height: 1.7; }
@@ -62,7 +62,7 @@
summary::before {
content: '▶';
font-size: 0.65rem;
color: #64748b;
color: #94a3b8;
transition: transform 0.15s;
}
details[open] summary::before { transform: rotate(90deg); }
@@ -89,13 +89,13 @@
#help-body h3 {
font-size: 0.8rem;
font-weight: 600;
color: #64748b;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0.75rem 0 0.25rem;
}
#loading { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
#loading { color: #94a3b8; font-size: 0.9rem; padding: 1rem 0; }
</style>
</head>
<body>

View File

@@ -40,7 +40,7 @@
.logo p {
font-size: 0.8rem;
color: #64748b;
color: #94a3b8;
margin-top: 0.25rem;
}

View File

@@ -30,7 +30,7 @@
.back-link {
display: inline-block;
font-size: 0.8rem;
color: #64748b;
color: #94a3b8;
text-decoration: none;
margin-bottom: 1.5rem;
}
@@ -40,7 +40,7 @@
margin-bottom: 1.75rem;
}
.logo h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; }
.logo p { font-size: 0.8rem; color: #64748b; margin-top: 0.2rem; }
.logo p { font-size: 0.8rem; color: #94a3b8; margin-top: 0.2rem; }
h2 {
font-size: 0.9rem;
@@ -73,7 +73,7 @@
transition: border-color 0.15s;
}
input:focus { border-color: #7c3aed; }
input[readonly] { color: #64748b; cursor: default; }
input[readonly] { color: #94a3b8; cursor: default; }
.field { margin-bottom: 1rem; }
@@ -109,11 +109,17 @@
.persona-list {
list-style: none;
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
.persona-list li a {
.persona-list li {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.persona-link {
display: inline-block;
padding: 0.3rem 0.75rem;
background: #0f1117;
@@ -124,14 +130,55 @@
text-decoration: none;
transition: border-color 0.15s;
}
.persona-list li a:hover { border-color: #7c3aed; }
.persona-list li em { color: #475569; font-size: 0.85rem; }
.persona-link:hover { border-color: #7c3aed; }
.persona-list li em { color: #94a3b8; font-size: 0.85rem; }
.persona-rename-toggle {
background: none;
border: none;
color: #94a3b8;
font-size: 0.85rem;
cursor: pointer;
padding: 0.2rem 0.4rem;
border-radius: 4px;
opacity: 0.7;
transition: opacity 0.15s, color 0.15s;
}
.persona-rename-toggle:hover { opacity: 1; color: #a78bfa; }
.persona-rename-form { display: flex; align-items: center; gap: 0.4rem; }
.persona-rename-form input[type="text"] {
width: 12rem;
padding: 0.3rem 0.6rem;
background: #0f1117;
border: 1px solid #7c3aed;
border-radius: 6px;
color: #e2e8f0;
font-size: 0.9rem;
outline: none;
}
.persona-rename-form button[type="submit"] {
width: auto;
padding: 0.3rem 0.75rem;
font-size: 0.85rem;
margin-top: 0;
}
.persona-rename-cancel {
background: none;
border: 1px solid #2d3148;
border-radius: 6px;
color: #94a3b8;
font-size: 0.85rem;
padding: 0.3rem 0.6rem;
cursor: pointer;
}
.persona-rename-cancel:hover { border-color: #94a3b8; color: #e2e8f0; }
.add-persona {
display: inline-block;
margin-top: 0.75rem;
font-size: 0.8rem;
color: #64748b;
color: #94a3b8;
text-decoration: none;
}
.add-persona:hover { color: #a78bfa; }
@@ -156,12 +203,33 @@
<label>Username</label>
<input type="text" value="{{ username }}" readonly>
</div>
<button type="button" id="show-rename-user" class="persona-rename-toggle"
style="opacity:0.7; font-size:0.8rem; padding:0.3rem 0.6rem; border:1px solid #2d3148; border-radius:6px; margin-top:0.25rem;">
✏ Change username
</button>
<form id="rename-user-form" method="POST" action="/settings/username"
style="display:none; margin-top:0.75rem;">
<div class="field">
<label for="new_username">New username</label>
<input type="text" id="new_username" name="new_username"
value="{{ username }}"
pattern="[a-z_][a-z0-9_\-]{0,31}" required autofocus>
<p style="font-size:0.75rem; color:#94a3b8; margin-top:0.3rem;">
Lowercase letters, digits, _ or - only. You will be logged out after renaming.
</p>
</div>
<div style="display:flex; gap:0.5rem;">
<button type="submit" style="flex:1; padding:0.5rem; background:#7c3aed; border:none; border-radius:6px; color:#fff; font-size:0.9rem; font-weight:600; cursor:pointer;">Save</button>
<button type="button" id="cancel-rename-user"
style="padding:0.5rem 0.9rem; background:none; border:1px solid #2d3148; border-radius:6px; color:#94a3b8; font-size:0.9rem; cursor:pointer;">Cancel</button>
</div>
</form>
</div>
<!-- Change password -->
<div class="section">
<h2>Change Password</h2>
<form method="POST" action="/settings/password">
<form method="POST" action="/settings/password" id="password-form">
<div class="field">
<label for="current_password">Current password</label>
<input type="password" id="current_password" name="current_password"
@@ -192,7 +260,8 @@
</div>
<script>
document.querySelector('form').addEventListener('submit', e => {
// Password confirmation check
document.getElementById('password-form').addEventListener('submit', e => {
const np = document.getElementById('new_password').value;
const cfm = document.getElementById('confirm_password').value;
if (np !== cfm) {
@@ -200,6 +269,37 @@
alert('New passwords do not match.');
}
});
// Username rename toggle
document.getElementById('show-rename-user').addEventListener('click', () => {
document.getElementById('show-rename-user').style.display = 'none';
document.getElementById('rename-user-form').style.display = 'block';
document.getElementById('new_username').focus();
});
document.getElementById('cancel-rename-user').addEventListener('click', () => {
document.getElementById('rename-user-form').style.display = 'none';
document.getElementById('show-rename-user').style.display = '';
});
// Persona rename toggle
document.querySelectorAll('.persona-rename-toggle').forEach(btn => {
btn.addEventListener('click', () => {
const p = btn.dataset.persona;
const form = document.querySelector(`.persona-rename-form[data-persona="${p}"]`);
btn.style.display = 'none';
form.style.display = 'flex';
form.querySelector('input[type="text"]').focus();
});
});
document.querySelectorAll('.persona-rename-cancel').forEach(btn => {
btn.addEventListener('click', () => {
const form = btn.closest('.persona-rename-form');
const p = form.dataset.persona;
const toggle = document.querySelector(`.persona-rename-toggle[data-persona="${p}"]`);
form.style.display = 'none';
toggle.style.display = '';
});
});
</script>
</body>
</html>

View File

@@ -33,7 +33,7 @@
}
.logo h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: 0.05em; color: #a78bfa; }
.logo p { font-size: 0.8rem; color: #64748b; margin-top: 0.25rem; }
.logo p { font-size: 0.8rem; color: #94a3b8; margin-top: 0.25rem; }
h2 {
font-size: 1rem;
@@ -52,7 +52,7 @@
margin-bottom: 0.4rem;
}
label small { font-weight: 400; color: #475569; }
label small { font-weight: 400; color: #94a3b8; }
input, select {
width: 100%;
@@ -71,7 +71,7 @@
.field { margin-bottom: 1rem; }
.hint { font-size: 0.75rem; color: #475569; margin-top: 0.3rem; }
.hint { font-size: 0.75rem; color: #94a3b8; margin-top: 0.3rem; }
button[type="submit"] {
width: 100%;
@@ -98,7 +98,7 @@
.step-label {
font-size: 0.7rem;
color: #475569;
color: #94a3b8;
text-align: right;
margin-bottom: 1rem;
}

View File

@@ -11,7 +11,7 @@
--inara-border: #3d2a55;
--accent: #c4935a;
--text: #e8e0f0;
--muted: #9080a8;
--muted: #b0a2c8;
--error-bg: #3b0f0f;
--error-border: #7f1d1d;
--error-text: #fca5a5;
@@ -59,7 +59,7 @@
--inara-border: #3d2a55;
--accent: #c4935a;
--text: #e8e0f0;
--muted: #9080a8;
--muted: #b0a2c8;
--error-bg: #3b0f0f;
--error-border: #7f1d1d;
--error-text: #fca5a5;
@@ -242,6 +242,37 @@
}
.session-delete-btn:hover { color: #e06c75; }
.session-rename-btn {
background: none;
border: none;
color: var(--muted);
font-size: 0.9rem;
line-height: 1;
padding: 2px 6px;
cursor: pointer;
border-radius: 3px;
flex-shrink: 0;
opacity: 0.4;
transition: opacity 0.15s, color 0.15s;
min-width: 24px;
text-align: center;
}
.session-item:hover .session-rename-btn { opacity: 1; }
.session-rename-btn:hover { color: var(--accent); }
.session-rename-input {
flex: 1;
min-width: 0;
background: var(--bg);
border: 1px solid var(--accent);
border-radius: 4px;
color: var(--text);
font-family: monospace;
font-size: 0.85rem;
padding: 1px 5px;
outline: none;
}
.session-id {
font-family: monospace;
font-size: 0.85rem;
@@ -254,7 +285,7 @@
}
.session-meta {
font-size: 0.72rem;
font-size: 0.78rem;
color: var(--muted);
white-space: nowrap;
text-align: right;