@@ -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" ;
3536import 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
3839function 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