@@ -236,10 +236,12 @@ pub struct SessionMeta {
236236 pub title : String ,
237237}
238238
239- /// First non-empty line of the first user message, truncated for display. Used
240- /// as a session's auto-title in the sidebar (no manual naming required).
239+ /// A session's display title. Prefers a manual title set on the `session_meta`
240+ /// header line (via session_set_title); otherwise derives one from the first
241+ /// user message (first non-empty line, truncated). Returns None if neither.
241242fn derive_session_title ( path : & std:: path:: Path ) -> Option < String > {
242243 let text = std:: fs:: read_to_string ( path) . ok ( ) ?;
244+ let mut from_user: Option < String > = None ;
243245 for line in text. lines ( ) {
244246 let line = line. trim ( ) ;
245247 if line. is_empty ( ) {
@@ -248,25 +250,71 @@ fn derive_session_title(path: &std::path::Path) -> Option<String> {
248250 let Ok ( v) = serde_json:: from_str :: < serde_json:: Value > ( line) else {
249251 continue ;
250252 } ;
251- if v. get ( "type" ) . and_then ( |t| t. as_str ( ) ) != Some ( "message" ) {
252- continue ;
253- }
254- if v. get ( "role" ) . and_then ( |r| r. as_str ( ) ) != Some ( "user" ) {
255- continue ;
256- }
257- let content = v. get ( "content" ) . and_then ( |c| c. as_array ( ) ) ?;
258- for block in content {
259- if block. get ( "type" ) . and_then ( |t| t. as_str ( ) ) == Some ( "text" ) {
260- if let Some ( txt) = block. get ( "text" ) . and_then ( |t| t. as_str ( ) ) {
261- let title = clean_title ( txt) ;
262- if !title. is_empty ( ) {
263- return Some ( title) ;
253+ match v. get ( "type" ) . and_then ( |t| t. as_str ( ) ) {
254+ // Manual title wins — return immediately.
255+ Some ( "session_meta" ) => {
256+ if let Some ( t) = v. get ( "title" ) . and_then ( |t| t. as_str ( ) ) {
257+ let t = t. trim ( ) ;
258+ if !t. is_empty ( ) {
259+ return Some ( clean_title ( t) ) ;
264260 }
265261 }
266262 }
263+ Some ( "message" ) if v. get ( "role" ) . and_then ( |r| r. as_str ( ) ) == Some ( "user" ) => {
264+ if from_user. is_none ( ) {
265+ if let Some ( content) = v. get ( "content" ) . and_then ( |c| c. as_array ( ) ) {
266+ for block in content {
267+ if block. get ( "type" ) . and_then ( |t| t. as_str ( ) ) == Some ( "text" ) {
268+ if let Some ( txt) = block. get ( "text" ) . and_then ( |t| t. as_str ( ) ) {
269+ let title = clean_title ( txt) ;
270+ if !title. is_empty ( ) {
271+ from_user = Some ( title) ;
272+ break ;
273+ }
274+ }
275+ }
276+ }
277+ }
278+ }
279+ }
280+ _ => { }
267281 }
268282 }
269- None
283+ from_user
284+ }
285+
286+ /// Set (or clear, with "") a session's manual title on its session_meta header.
287+ #[ tauri:: command]
288+ pub fn session_set_title ( id : String , title : String ) -> Result < ( ) , String > {
289+ let Some ( home) = dirs:: home_dir ( ) else {
290+ return Err ( "no home directory" . into ( ) ) ;
291+ } ;
292+ let path = home
293+ . join ( ".deepcode" )
294+ . join ( "sessions" )
295+ . join ( format ! ( "{id}.jsonl" ) ) ;
296+ let text = std:: fs:: read_to_string ( & path) . map_err ( |e| format ! ( "read {}: {}" , path. display( ) , e) ) ?;
297+ let trimmed = title. trim ( ) ;
298+ let mut lines: Vec < String > = text. lines ( ) . map ( |l| l. to_string ( ) ) . collect ( ) ;
299+ let mut updated = false ;
300+ for line in lines. iter_mut ( ) {
301+ let Ok ( mut v) = serde_json:: from_str :: < serde_json:: Value > ( line. trim ( ) ) else {
302+ continue ;
303+ } ;
304+ if v. get ( "type" ) . and_then ( |t| t. as_str ( ) ) == Some ( "session_meta" ) {
305+ v[ "title" ] = serde_json:: Value :: String ( trimmed. to_string ( ) ) ;
306+ * line = v. to_string ( ) ;
307+ updated = true ;
308+ break ;
309+ }
310+ }
311+ if !updated {
312+ // No meta header (older session) — prepend one carrying the title.
313+ let meta = serde_json:: json!( { "type" : "session_meta" , "id" : id, "title" : trimmed } ) ;
314+ lines. insert ( 0 , meta. to_string ( ) ) ;
315+ }
316+ std:: fs:: write ( & path, lines. join ( "\n " ) + "\n " )
317+ . map_err ( |e| format ! ( "write {}: {}" , path. display( ) , e) )
270318}
271319
272320/// Strip a leading <system-reminder>…</system-reminder> block (CLI-created
@@ -409,4 +457,36 @@ mod contract_tests {
409457 assert_eq ! ( t. chars( ) . count( ) , 49 ) ; // 48 + '…'
410458 assert ! ( t. ends_with( '…' ) ) ;
411459 }
460+
461+ #[ test]
462+ fn derive_title_prefers_meta_then_first_user ( ) {
463+ use std:: io:: Write ;
464+ let dir = std:: env:: temp_dir ( ) ;
465+ let pid = std:: process:: id ( ) ;
466+
467+ // A manual title on the meta header wins over the first user message.
468+ let p1 = dir. join ( format ! ( "dc-title-{pid}-a.jsonl" ) ) ;
469+ let mut f = std:: fs:: File :: create ( & p1) . unwrap ( ) ;
470+ writeln ! ( f, r#"{{"type":"session_meta","id":"x","title":"My Custom Name"}}"# ) . unwrap ( ) ;
471+ writeln ! (
472+ f,
473+ r#"{{"type":"message","role":"user","content":[{{"type":"text","text":"the prompt"}}]}}"#
474+ )
475+ . unwrap ( ) ;
476+ assert_eq ! ( derive_session_title( & p1) . as_deref( ) , Some ( "My Custom Name" ) ) ;
477+
478+ // No meta title → derive from the first user message (CJK).
479+ let p2 = dir. join ( format ! ( "dc-title-{pid}-b.jsonl" ) ) ;
480+ let mut f2 = std:: fs:: File :: create ( & p2) . unwrap ( ) ;
481+ writeln ! ( f2, r#"{{"type":"session_meta","id":"y"}}"# ) . unwrap ( ) ;
482+ writeln ! (
483+ f2,
484+ r#"{{"type":"message","role":"user","content":[{{"type":"text","text":"做一个游戏"}}]}}"#
485+ )
486+ . unwrap ( ) ;
487+ assert_eq ! ( derive_session_title( & p2) . as_deref( ) , Some ( "做一个游戏" ) ) ;
488+
489+ std:: fs:: remove_file ( & p1) . ok ( ) ;
490+ std:: fs:: remove_file ( & p2) . ok ( ) ;
491+ }
412492}
0 commit comments