diff --git a/src/praisonaiui/server.py b/src/praisonaiui/server.py index 144c201..2233a79 100644 --- a/src/praisonaiui/server.py +++ b/src/praisonaiui/server.py @@ -1303,9 +1303,11 @@ async def patch_session(request: Request) -> JSONResponse: body = await request.json() except Exception: return JSONResponse({"error": "Invalid JSON"}, status_code=400) - title = body.get("title") + title = body.get("title") if isinstance(body, dict) else None if title is not None: - await _datastore.update_session(session_id, title=title) + if not isinstance(title, str) or not title.strip(): + return JSONResponse({"error": "title must be a non-empty string"}, status_code=400) + await _datastore.update_session(session_id, title=title.strip()[:200]) return JSONResponse({"status": "updated", "session_id": session_id}) diff --git a/src/praisonaiui/templates/frontend/plugins/views/chat.js b/src/praisonaiui/templates/frontend/plugins/views/chat.js index edb346b..a2b82d6 100644 --- a/src/praisonaiui/templates/frontend/plugins/views/chat.js +++ b/src/praisonaiui/templates/frontend/plugins/views/chat.js @@ -244,6 +244,9 @@ function injectStyles() { .chat-sess-item.active { background:rgba(var(--db-accent-rgb,100,100,255),.15); } .chat-sess-item .sess-title { font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .chat-sess-item .sess-meta { font-size:11px; color:var(--db-text-dim); } + .chat-sess-item { position:relative; } + .chat-sess-item .sess-rename { position:absolute; top:6px; right:6px; display:none; background:none; border:none; color:var(--db-text-dim); cursor:pointer; font-size:12px; padding:2px 4px; } + .chat-sess-item:hover .sess-rename, .chat-sess-item .sess-rename:focus { display:block; } .chat-main-panel { flex:1; display:flex; flex-direction:column; min-width:0; } .chat-top-bar { padding:10px 16px; border-bottom:1px solid var(--db-border); display:flex; align-items:center; justify-content:space-between; gap:10px; } @@ -1103,10 +1106,41 @@ function renderSessionList() { `; el.title = id; el.addEventListener('click', () => loadSession(id)); + if (!platformName && id) { + const btn = document.createElement('button'); + btn.className = 'sess-rename'; + btn.title = 'Rename session'; + btn.setAttribute('aria-label', 'Rename session'); + btn.textContent = '✎'; + btn.addEventListener('click', (e) => { e.stopPropagation(); renameSession(id, title); }); + el.appendChild(btn); + } list.appendChild(el); }); } +async function renameSession(sessionId, currentTitle) { + const next = (window.prompt('Rename session', currentTitle || '') || '').trim(); + if (!next || next === currentTitle) return; + try { + const resp = await fetch('/sessions/' + encodeURIComponent(sessionId), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: next }), + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + const s = allSessions.find((x) => (x.id || x.session_id) === sessionId); + if (s) { s.title = next; delete s.name; } + renderSessionList(); + if (sessionId === currentSessionId) { + const header = document.getElementById('chat-header-title'); + if (header) header.textContent = next; + } + } catch (e) { + console.warn('[Chat] Failed to rename session:', e); + } +} + async function loadSession(sessionId) { currentSessionId = sessionId; refreshStreamingUI(); @@ -1158,7 +1192,8 @@ async function loadSession(sessionId) { title.textContent = (sessionIcon || '📨') + ' ' + displayName; } } else if (title) { - title.textContent = 'Session ' + sessionId.substring(0, 8); + const cached = allSessions.find((x) => (x.id || x.session_id) === sessionId); + title.textContent = (cached && (cached.name || cached.title)) || ('Session ' + sessionId.substring(0, 8)); } } catch (e) { console.warn('[Chat] Failed to load session:', e); diff --git a/tests/unit/test_chat_session_rename.py b/tests/unit/test_chat_session_rename.py new file mode 100644 index 0000000..ce7f590 --- /dev/null +++ b/tests/unit/test_chat_session_rename.py @@ -0,0 +1,49 @@ +"""Issue #331 — rename a session from the chat rail via the existing PATCH route.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from starlette.applications import Starlette +from starlette.routing import Route +from starlette.testclient import TestClient + +import praisonaiui.server as server +from praisonaiui.datastore import MemoryDataStore + +CHAT = Path("src/praisonaiui/templates/frontend/plugins/views/chat.js") + + +def _client(monkeypatch): + ds = MemoryDataStore() + monkeypatch.setattr(server, "_datastore", ds) + app = Starlette(routes=[Route("/sessions/{session_id}", server.patch_session, methods=["PATCH"])]) + sid = asyncio.run(ds.create_session())["id"] + return TestClient(app), ds, sid + + +def test_patch_renames_and_trims(monkeypatch): + client, ds, sid = _client(monkeypatch) + r = client.patch(f"/sessions/{sid}", json={"title": " My chat "}) + assert r.status_code == 200 + assert asyncio.run(ds.get_session(sid))["title"] == "My chat" + + +def test_patch_rejects_blank_or_non_string_title(monkeypatch): + client, _, sid = _client(monkeypatch) + assert client.patch(f"/sessions/{sid}", json={"title": " "}).status_code == 400 + assert client.patch(f"/sessions/{sid}", json={"title": 123}).status_code == 400 + + +def test_patch_unknown_session_404(monkeypatch): + client, _, _ = _client(monkeypatch) + assert client.patch("/sessions/nope", json={"title": "x"}).status_code == 404 + + +def test_rail_has_rename_button_wired_to_patch(): + src = CHAT.read_text() + assert "async function renameSession(sessionId, currentTitle)" in src + assert "method: 'PATCH'" in src + assert "renameSession(id, title)" in src + assert "e.stopPropagation()" in src