Skip to content

Commit b98bce9

Browse files
Merge pull request #93 from SaplingLearn/feat/editable-session-names
feat(learn): make tutor session names editable
2 parents 3fb23d3 + 8824a6e commit b98bce9

8 files changed

Lines changed: 282 additions & 25 deletions

File tree

backend/models/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ class EndSessionBody(BaseModel):
2727
user_id: str = "" # Required to discard a lazy (not-yet-persisted) session safely
2828

2929

30+
class RenameSessionBody(BaseModel):
31+
user_id: str
32+
topic: str
33+
34+
3035
class ActionBody(BaseModel):
3136
session_id: str
3237
user_id: str = "user_andres"

backend/routes/learn.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from agents.chat_tutor import agent_for_mode
1515
from agents.deps import SaplingDeps
1616
from db.connection import table
17-
from models import StartSessionBody, ChatBody, EndSessionBody, ActionBody, ModeSwitchBody
17+
from models import StartSessionBody, ChatBody, EndSessionBody, ActionBody, ModeSwitchBody, RenameSessionBody
1818
from services.auth_guard import require_self, get_session_user_id
1919
from services.encryption import encrypt_if_present, encrypt_json, decrypt_if_present, decrypt_json
2020
from services.gemini_service import (
@@ -713,6 +713,35 @@ def list_sessions(user_id: str, request: Request, limit: int = 10):
713713
return {"sessions": result}
714714

715715

716+
@router.patch("/sessions/{session_id}")
717+
def rename_session(session_id: str, body: RenameSessionBody, request: Request):
718+
require_self(body.user_id, request)
719+
topic = body.topic.strip()
720+
if not topic or len(topic) > 120:
721+
raise HTTPException(status_code=400, detail="Topic must be 1-120 characters")
722+
723+
if session_id in PENDING_SESSIONS:
724+
pending = PENDING_SESSIONS[session_id]
725+
if pending["user_id"] != body.user_id:
726+
raise HTTPException(status_code=403, detail="Session user mismatch")
727+
pending["topic"] = topic
728+
return {"updated": True, "session": {"id": session_id, "topic": topic}}
729+
730+
owner_rows = table("sessions").select(
731+
"user_id", filters={"id": f"eq.{session_id}"}, limit=1
732+
)
733+
if not owner_rows:
734+
raise HTTPException(status_code=404, detail="Session not found")
735+
if owner_rows[0].get("user_id") != body.user_id:
736+
raise HTTPException(status_code=403, detail="Session user mismatch")
737+
738+
table("sessions").update(
739+
{"topic": topic},
740+
filters={"id": f"eq.{session_id}"},
741+
)
742+
return {"updated": True, "session": {"id": session_id, "topic": topic}}
743+
744+
716745
@router.delete("/sessions/{session_id}")
717746
def delete_session(session_id: str, request: Request, user_id: str | None = Query(None)):
718747
if user_id:

backend/tests/test_learn_routes.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,93 @@ def test_message_is_saved_to_db(self):
281281
assert len(insert_calls) >= 1
282282

283283

284+
# ── PATCH /api/learn/sessions/{session_id} ────────────────────────────────────
285+
286+
class TestRenameSession:
287+
def test_renames_persisted_session(self):
288+
sessions_mock = MagicMock()
289+
sessions_mock.select.return_value = [{"user_id": "u1"}]
290+
291+
def factory(name):
292+
if name == "sessions":
293+
return sessions_mock
294+
m = MagicMock()
295+
m.select.return_value = []
296+
return m
297+
298+
with patch("routes.learn.table", side_effect=factory):
299+
r = client.patch(
300+
"/api/learn/sessions/s1",
301+
json={"user_id": "u1", "topic": " New Topic "},
302+
)
303+
304+
assert r.status_code == 200
305+
body = r.json()
306+
assert body == {"updated": True, "session": {"id": "s1", "topic": "New Topic"}}
307+
sessions_mock.update.assert_called_once_with(
308+
{"topic": "New Topic"},
309+
filters={"id": "eq.s1"},
310+
)
311+
312+
def test_renames_pending_session(self):
313+
from routes.learn import PENDING_SESSIONS
314+
PENDING_SESSIONS["pending-1"] = {
315+
"user_id": "u1",
316+
"topic": "Old",
317+
"mode": "socratic",
318+
"assistant_reply": "hi",
319+
}
320+
try:
321+
r = client.patch(
322+
"/api/learn/sessions/pending-1",
323+
json={"user_id": "u1", "topic": "Renamed"},
324+
)
325+
assert r.status_code == 200
326+
assert r.json() == {"updated": True, "session": {"id": "pending-1", "topic": "Renamed"}}
327+
assert PENDING_SESSIONS["pending-1"]["topic"] == "Renamed"
328+
finally:
329+
PENDING_SESSIONS.pop("pending-1", None)
330+
331+
def test_empty_topic_returns_400(self):
332+
r = client.patch(
333+
"/api/learn/sessions/s1",
334+
json={"user_id": "u1", "topic": " "},
335+
)
336+
assert r.status_code == 400
337+
338+
def test_topic_too_long_returns_400(self):
339+
r = client.patch(
340+
"/api/learn/sessions/s1",
341+
json={"user_id": "u1", "topic": "x" * 121},
342+
)
343+
assert r.status_code == 400
344+
345+
def test_wrong_user_returns_403(self):
346+
sessions_mock = MagicMock()
347+
sessions_mock.select.return_value = [{"user_id": "other_user"}]
348+
349+
def factory(name):
350+
if name == "sessions":
351+
return sessions_mock
352+
m = MagicMock()
353+
m.select.return_value = []
354+
return m
355+
356+
with patch("routes.learn.table", side_effect=factory):
357+
r = client.patch(
358+
"/api/learn/sessions/s1",
359+
json={"user_id": "u1", "topic": "Renamed"},
360+
)
361+
assert r.status_code == 403
362+
363+
def test_missing_session_returns_404(self):
364+
with patch("routes.learn.table") as t:
365+
t.return_value.select.return_value = []
366+
r = client.patch(
367+
"/api/learn/sessions/nonexistent",
368+
json={"user_id": "u1", "topic": "Renamed"},
369+
)
370+
assert r.status_code == 404
284371
# ── _resolve_legacy_model ─────────────────────────────────────────────────────
285372

286373
class TestResolveLegacyModel:

frontend/src/components/screens/Dashboard.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
type Assignment,
2222
} from "@/lib/api";
2323
import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/types";
24-
import type { GraphNode, GraphEdge } from "@/lib/data";
24+
import { paletteFor, type GraphNode, type GraphEdge } from "@/lib/data";
2525

2626
const QUOTES = [
2727
"Learning is the only thing the mind never exhausts, never fears, and never regrets. — da Vinci",
@@ -38,7 +38,10 @@ function apiToGraphNode(n: ApiNode, courses: EnrolledCourse[]): GraphNode {
3838
id: n.id,
3939
name: n.concept_name,
4040
subject: n.subject,
41-
color: n.course_color || course?.color || "var(--c-sage)",
41+
color:
42+
n.course_color ||
43+
course?.color ||
44+
paletteFor(n.course_id || course?.course_id || n.subject),
4245
is_subject_root: n.is_subject_root,
4346
mastery_tier: n.mastery_tier === "subject_root" ? "mastered" : n.mastery_tier,
4447
mastery_score: n.mastery_score,

frontend/src/components/screens/Learn.tsx

Lines changed: 126 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
getSessions,
2424
resumeSession,
2525
deleteSession,
26+
renameSession,
2627
endSession,
2728
switchMode,
2829
learnAction,
@@ -33,15 +34,18 @@ import {
3334
type EnrolledCourse,
3435
} from "@/lib/api";
3536
import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/types";
36-
import type { GraphNode, GraphEdge } from "@/lib/data";
37+
import { paletteFor, type GraphNode, type GraphEdge } from "@/lib/data";
3738

3839
function apiToGraphNode(n: ApiNode, courses: EnrolledCourse[]): GraphNode {
3940
const course = courses.find((c) => c.course_name === n.subject);
4041
return {
4142
id: n.id,
4243
name: n.concept_name,
4344
subject: n.subject,
44-
color: n.course_color || course?.color || "var(--c-sage)",
45+
color:
46+
n.course_color ||
47+
course?.color ||
48+
paletteFor(n.course_id || course?.course_id || n.subject),
4549
is_subject_root: n.is_subject_root,
4650
mastery_tier: n.mastery_tier === "subject_root" ? "mastered" : n.mastery_tier,
4751
mastery_score: n.mastery_score,
@@ -112,6 +116,8 @@ function LearnInner() {
112116
const [mobileTab, setMobileTab] = useState<"chat" | "graph">("chat");
113117
const idCounter = useRef(0);
114118
const msgId = () => `m-${++idCounter.current}`;
119+
// Tracks each session's last server-confirmed topic so back-to-back rename failures revert to the right value.
120+
const confirmedTopicsRef = useRef<Map<string, string>>(new Map());
115121

116122
// Initial data load
117123
useEffect(() => {
@@ -125,7 +131,11 @@ function LearnInner() {
125131
getGraph(userId).catch(() => ({ nodes: [] as any[], edges: [] as any[], stats: {} })),
126132
]);
127133
if (cancelled) return;
128-
setRecentSessions((sRes.sessions ?? []).filter(s => s.message_count > 0));
134+
const filteredSessions = (sRes.sessions ?? []).filter(s => s.message_count > 0);
135+
setRecentSessions(filteredSessions);
136+
confirmedTopicsRef.current = new Map(
137+
filteredSessions.map(s => [s.id, s.topic] as const),
138+
);
129139
setCourses(cRes.courses ?? []);
130140
const nodes = (gRes.nodes ?? []) as Array<{ id: string; concept_name?: string; name?: string; course_id?: string | null; is_subject_root?: boolean }>;
131141
const courseById = new Map((cRes.courses ?? []).map(c => [c.course_id, c]));
@@ -208,6 +218,27 @@ function LearnInner() {
208218
}
209219
};
210220

221+
const handleRenameSession = useCallback(async (s: Session, newTopic: string) => {
222+
if (!userId) return;
223+
const trimmed = newTopic.trim();
224+
if (!trimmed || trimmed.length > 120 || trimmed === s.topic) return;
225+
setRecentSessions(prev => prev.map(p => (p.id === s.id ? { ...p, topic: trimmed } : p)));
226+
try {
227+
await renameSession(s.id, userId, trimmed);
228+
confirmedTopicsRef.current.set(s.id, trimmed);
229+
} catch (err) {
230+
// Server is authoritative — resync from it instead of guessing a revert
231+
// target, which is otherwise racy when multiple renames overlap.
232+
const res = await getSessions(userId, 10).catch(() => null);
233+
if (res) {
234+
const filtered = (res.sessions ?? []).filter(x => x.message_count > 0);
235+
setRecentSessions(filtered);
236+
confirmedTopicsRef.current = new Map(filtered.map(x => [x.id, x.topic] as const));
237+
}
238+
toast.error(err instanceof Error ? err.message : "Rename failed.");
239+
}
240+
}, [userId, toast]);
241+
211242
const send = useCallback(async (userText: string) => {
212243
if (!userText.trim() || !sessionId || !userId) return;
213244
setMessages(m => [
@@ -488,7 +519,7 @@ function LearnInner() {
488519
<div style={{ fontSize: 12, color: "var(--text-muted)" }}>No recent sessions yet.</div>
489520
)}
490521
{recentSessions.map(s => (
491-
<SessionRow key={s.id} s={s} onResume={handleResume} onDelete={handleDeleteSession} />
522+
<SessionRow key={s.id} s={s} onResume={handleResume} onDelete={handleDeleteSession} onRename={handleRenameSession} />
492523
))}
493524
</div>
494525
</div>
@@ -712,12 +743,41 @@ function BackToLearnLink({ onClick }: { onClick: () => void }) {
712743
);
713744
}
714745

715-
function SessionRow({ s, onResume, onDelete }: {
746+
function SessionRow({ s, onResume, onDelete, onRename }: {
716747
s: Session;
717748
onResume: (s: Session) => void;
718749
onDelete: (s: Session) => void;
750+
onRename: (s: Session, newTopic: string) => void;
719751
}) {
720752
const del = useConfirm(() => onDelete(s), 3000);
753+
const [editing, setEditing] = useState(false);
754+
const [draft, setDraft] = useState(s.topic);
755+
// Esc unmounts the input, which fires blur → commitEdit. The blur closure
756+
// still holds the typed `draft`, so without this guard Esc would commit.
757+
const cancellingRef = useRef(false);
758+
759+
const startEdit = () => {
760+
setDraft(s.topic);
761+
setEditing(true);
762+
};
763+
764+
const commitEdit = () => {
765+
if (cancellingRef.current) {
766+
cancellingRef.current = false;
767+
setEditing(false);
768+
return;
769+
}
770+
const trimmed = draft.trim();
771+
if (trimmed && trimmed !== s.topic) onRename(s, trimmed);
772+
setEditing(false);
773+
};
774+
775+
const cancelEdit = () => {
776+
cancellingRef.current = true;
777+
setDraft(s.topic);
778+
setEditing(false);
779+
};
780+
721781
return (
722782
<div
723783
style={{
@@ -730,24 +790,70 @@ function SessionRow({ s, onResume, onDelete }: {
730790
marginBottom: 6,
731791
}}
732792
>
733-
<button
734-
onClick={() => onResume(s)}
735-
style={{
736-
flex: 1,
737-
textAlign: "left",
738-
display: "flex",
739-
flexDirection: "column",
740-
gap: 2,
741-
}}
742-
>
743-
<span style={{ fontSize: 13, fontWeight: 600 }}>{s.topic}</span>
744-
<span style={{ fontSize: 11, color: "var(--text-muted)" }}>
745-
{s.mode} · {s.message_count} msg{s.message_count === 1 ? "" : "s"}
746-
</span>
747-
</button>
793+
{editing ? (
794+
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 2 }}>
795+
<input
796+
autoFocus
797+
aria-label="Session name"
798+
value={draft}
799+
maxLength={120}
800+
onChange={e => setDraft(e.target.value)}
801+
onKeyDown={e => {
802+
if (e.key === "Enter") {
803+
e.preventDefault();
804+
commitEdit();
805+
} else if (e.key === "Escape") {
806+
e.preventDefault();
807+
cancelEdit();
808+
}
809+
}}
810+
onBlur={commitEdit}
811+
style={{
812+
fontSize: 13,
813+
fontWeight: 600,
814+
padding: "2px 4px",
815+
border: "1px solid var(--border)",
816+
borderRadius: "var(--r-sm)",
817+
background: "var(--bg)",
818+
color: "var(--text)",
819+
outline: "none",
820+
}}
821+
/>
822+
<span style={{ fontSize: 11, color: "var(--text-muted)" }}>
823+
{s.mode} · {s.message_count} msg{s.message_count === 1 ? "" : "s"}
824+
</span>
825+
</div>
826+
) : (
827+
<button
828+
onClick={() => onResume(s)}
829+
style={{
830+
flex: 1,
831+
textAlign: "left",
832+
display: "flex",
833+
flexDirection: "column",
834+
gap: 2,
835+
}}
836+
>
837+
<span style={{ fontSize: 13, fontWeight: 600 }}>{s.topic}</span>
838+
<span style={{ fontSize: 11, color: "var(--text-muted)" }}>
839+
{s.mode} · {s.message_count} msg{s.message_count === 1 ? "" : "s"}
840+
</span>
841+
</button>
842+
)}
843+
{!editing && (
844+
<button
845+
className="btn btn--ghost btn--sm"
846+
onClick={startEdit}
847+
aria-label="Rename session"
848+
title="Rename"
849+
>
850+
<Icon name="pencil" size={12} />
851+
</button>
852+
)}
748853
<button
749854
className={del.armed ? "btn btn--danger btn--sm" : "btn btn--ghost btn--sm"}
750855
onClick={del.trigger}
856+
disabled={editing}
751857
aria-label={del.armed ? "Confirm delete" : "Delete session"}
752858
title={del.armed ? "Click again to confirm" : "Delete"}
753859
>

0 commit comments

Comments
 (0)