@@ -498,6 +498,7 @@ fn read_session_head(path: &Path, max_lines: usize) -> (Option<String>, usize) {
498498
499499 let reader = BufReader :: new ( file) ;
500500 let mut summary = None ;
501+ let mut first_user_message: Option < String > = None ;
501502 let mut message_count = 0 ;
502503
503504 for line in reader. lines ( ) . take ( max_lines) {
@@ -509,15 +510,90 @@ fn read_session_head(path: &Path, max_lines: usize) -> (Option<String>, usize) {
509510 if parsed. line_type . as_deref ( ) == Some ( "summary" ) {
510511 summary = parsed. summary ;
511512 }
512- if parsed. line_type . as_deref ( ) == Some ( "user" )
513- || parsed. line_type . as_deref ( ) == Some ( "assistant" )
514- {
513+ if parsed. line_type . as_deref ( ) == Some ( "user" ) {
514+ message_count += 1 ;
515+ // Capture first user message as fallback summary
516+ if first_user_message. is_none ( ) {
517+ if let Some ( msg) = & parsed. message {
518+ if let Some ( content) = & msg. content {
519+ // Extract text from content (can be string or array)
520+ let text_content = match content {
521+ serde_json:: Value :: String ( s) => Some ( s. clone ( ) ) ,
522+ serde_json:: Value :: Array ( arr) => {
523+ // Find first text block
524+ arr. iter ( ) . find_map ( |item| {
525+ if item. get ( "type" ) . and_then ( |t| t. as_str ( ) ) == Some ( "text" ) {
526+ item. get ( "text" ) . and_then ( |t| t. as_str ( ) ) . map ( |s| s. to_string ( ) )
527+ } else {
528+ None
529+ }
530+ } )
531+ }
532+ _ => None ,
533+ } ;
534+ if let Some ( text) = text_content {
535+ // Apply restore_slash_command BEFORE truncation to preserve XML structure
536+ let restored = restore_slash_command ( & text) ;
537+ // Truncate to reasonable length for display (UTF-8 safe)
538+ let display = if restored. chars ( ) . count ( ) > 80 {
539+ format ! ( "{}..." , restored. chars( ) . take( 80 ) . collect:: <String >( ) )
540+ } else {
541+ restored
542+ } ;
543+ first_user_message = Some ( display) ;
544+ }
545+ }
546+ }
547+ }
548+ }
549+ if parsed. line_type . as_deref ( ) == Some ( "assistant" ) {
515550 message_count += 1 ;
516551 }
517552 }
518553 }
519554
520- ( summary, message_count)
555+ // Use summary if available, otherwise fall back to first user message
556+ let final_summary = summary. or ( first_user_message) . map ( |s| restore_slash_command ( & s) ) ;
557+ ( final_summary, message_count)
558+ }
559+
560+ /// Convert <command-message>...</command-message><command-name>/cmd</command-name> to /cmd format
561+ fn restore_slash_command ( content : & str ) -> String {
562+ use regex:: Regex ;
563+ lazy_static:: lazy_static! {
564+ // Extract command name
565+ static ref NAME_RE : Regex = Regex :: new( r"<command-name>(/[^<]+)</command-name>" ) . unwrap( ) ;
566+ // Extract args (handles multi-line)
567+ static ref ARGS_RE : Regex = Regex :: new( r"(?s)<command-args>(.*?)</command-args>" ) . unwrap( ) ;
568+ // Strip all command-related XML tags
569+ static ref STRIP_RE : Regex = Regex :: new( r"(?s)<command-message>.*?</command-message>|</?command-[^>]*>" ) . unwrap( ) ;
570+ }
571+
572+ // Extract command name and args first
573+ let cmd = NAME_RE . captures ( content)
574+ . and_then ( |c| c. get ( 1 ) )
575+ . map ( |m| m. as_str ( ) . to_string ( ) ) ;
576+ let args = ARGS_RE . captures ( content)
577+ . and_then ( |c| c. get ( 1 ) )
578+ . map ( |m| m. as_str ( ) . trim ( ) . to_string ( ) )
579+ . filter ( |s| !s. is_empty ( ) ) ;
580+
581+ // Build result: command + args, then strip remaining tags
582+ let prefix = match ( cmd, args) {
583+ ( Some ( c) , Some ( a) ) => format ! ( "{} {}" , c, a) ,
584+ ( Some ( c) , None ) => c,
585+ _ => String :: new ( ) ,
586+ } ;
587+
588+ // Strip all command tags from original content
589+ let cleaned = STRIP_RE . replace_all ( content, "" ) . trim ( ) . to_string ( ) ;
590+
591+ // If we extracted a command, return it; otherwise return cleaned content
592+ if prefix. is_empty ( ) {
593+ cleaned
594+ } else {
595+ prefix
596+ }
521597}
522598
523599/// Build session index from history.jsonl (fast: only reads one file)
@@ -591,8 +667,8 @@ async fn list_all_sessions() -> Result<Vec<Session>, String> {
591667 // Only read head for summary (first 20 lines should be enough)
592668 let ( summary, head_msg_count) = read_session_head ( & session_path, 20 ) ;
593669
594- // Use display as fallback summary
595- let final_summary = summary. or_else ( || display. clone ( ) ) ;
670+ // Use display as fallback summary (also needs restore_slash_command)
671+ let final_summary = summary. or_else ( || display. clone ( ) . map ( |d| restore_slash_command ( & d ) ) ) ;
596672
597673 // Use file mtime for accurate last_modified
598674 let metadata = fs:: metadata ( & session_path) . ok ( ) ;
@@ -4291,6 +4367,16 @@ fn get_session_file_path(project_id: String, session_id: String) -> Result<Strin
42914367 Ok ( path. to_string_lossy ( ) . to_string ( ) )
42924368}
42934369
4370+ #[ tauri:: command]
4371+ fn get_session_summary ( project_id : String , session_id : String ) -> Result < Option < String > , String > {
4372+ let path = get_session_path ( & project_id, & session_id) ;
4373+ if !path. exists ( ) {
4374+ return Err ( "Session file not found" . to_string ( ) ) ;
4375+ }
4376+ let ( summary, _) = read_session_head ( & path, 20 ) ;
4377+ Ok ( summary)
4378+ }
4379+
42944380#[ tauri:: command]
42954381fn copy_to_clipboard ( text : String ) -> Result < ( ) , String > {
42964382 let mut clipboard = arboard:: Clipboard :: new ( ) . map_err ( |e| e. to_string ( ) ) ?;
@@ -6171,6 +6257,7 @@ pub fn run() {
61716257 reveal_path,
61726258 open_path,
61736259 get_session_file_path,
6260+ get_session_summary,
61746261 copy_to_clipboard,
61756262 get_settings_path,
61766263 get_mcp_config_path,
0 commit comments