@@ -366,7 +366,7 @@ public List<EventRecord> GetRecentEvents(int limit = 50)
366366 /// Enriches tabs with copilot session IDs from earlier captures if the last capture lost them
367367 /// (copilot process may have ended before window closed).
368368 /// </summary>
369- public List < CaptureTab > GetLastKnownTabs ( string windowId )
369+ public List < CaptureTab > GetLastKnownTabs ( string windowId , string ? referenceTime = null )
370370 {
371371 using var cmd = _conn . CreateCommand ( ) ;
372372 cmd . CommandText = """
@@ -418,7 +418,7 @@ ORDER BY cw.capture_id DESC LIMIT 1
418418
419419 // Enrich: if any tab lost its copilot session ID (copilot ended before window closed),
420420 // recover it from earlier captures for the same window
421- EnrichTabsWithHistoricalSessionIds ( windowId , tabs ) ;
421+ EnrichTabsWithHistoricalSessionIds ( windowId , tabs , referenceTime ) ;
422422
423423 return tabs ;
424424 }
@@ -427,7 +427,7 @@ ORDER BY cw.capture_id DESC LIMIT 1
427427 /// Recover copilot session IDs from earlier captures when the last capture lost them.
428428 /// Matches tabs by ordinal position within the same window.
429429 /// </summary>
430- void EnrichTabsWithHistoricalSessionIds ( string windowId , List < CaptureTab > tabs )
430+ void EnrichTabsWithHistoricalSessionIds ( string windowId , List < CaptureTab > tabs , string ? referenceTime = null )
431431 {
432432 // Find all distinct copilot session IDs ever seen for this window, with their ordinal and path
433433 using var cmd = _conn . CreateCommand ( ) ;
@@ -465,7 +465,7 @@ AND ct.parent_ordinal IS NULL
465465 if ( sessionByOrdinal . Count == 0 )
466466 {
467467 // No history for this window — fall back to scanning all copilot sessions on disk
468- EnrichFromCopilotSessionStore ( tabs ) ;
468+ EnrichFromCopilotSessionStore ( tabs , referenceTime ) ;
469469 return ;
470470 }
471471
@@ -514,36 +514,50 @@ AND ct.parent_ordinal IS NULL
514514 }
515515
516516 // Final fallback: scan disk for any unmatched tabs
517- EnrichFromCopilotSessionStore ( tabs ) ;
517+ EnrichFromCopilotSessionStore ( tabs , referenceTime ) ;
518518 }
519519
520520 /// <summary>
521521 /// Last-resort enrichment: scan all copilot session-state directories on disk
522- /// and match tab titles to session summaries. Useful for OLD windows captured
523- /// before session ID tracking was reliable.
522+ /// and match unmatched tabs by PATH (CWD) and TITLE, using nearest TIMESTAMP
523+ /// to the window's reference time as tie-breaker. Restores session continuity
524+ /// for closed windows captured before reliable session ID tracking.
524525 /// </summary>
525- static void EnrichFromCopilotSessionStore ( List < CaptureTab > tabs )
526+ static void EnrichFromCopilotSessionStore ( List < CaptureTab > tabs , string ? referenceTime = null )
526527 {
527528 var sessionsDir = Path . Combine (
528529 Environment . GetFolderPath ( Environment . SpecialFolder . UserProfile ) ,
529530 ".copilot" , "session-state" ) ;
530531 if ( ! Directory . Exists ( sessionsDir ) ) return ;
531532
532533 // Check if any tabs need enrichment
533- if ( ! tabs . Any ( t => t . CopilotSessionId == null && ! string . IsNullOrEmpty ( t . Title ) ) ) return ;
534+ if ( ! tabs . Any ( t => t . CopilotSessionId == null ) ) return ;
534535
535- // Build summary → (sessionId, cwd, updated) lookup from disk
536+ DateTime ? refTime = null ;
537+ if ( ! string . IsNullOrEmpty ( referenceTime )
538+ && DateTime . TryParse ( referenceTime , System . Globalization . CultureInfo . InvariantCulture ,
539+ System . Globalization . DateTimeStyles . AssumeLocal , out var parsedRef ) )
540+ {
541+ refTime = parsedRef ;
542+ }
543+
544+ // Collect already-claimed session IDs to avoid double-assignment
545+ var claimed = new HashSet < string > (
546+ tabs . Where ( t => t . CopilotSessionId != null ) . Select ( t => t . CopilotSessionId ! ) ,
547+ StringComparer . OrdinalIgnoreCase ) ;
548+
549+ // Build sessions catalog from disk
536550 var diskSessions = new List < ( string SessionId , string Summary , string Cwd , DateTime Updated ) > ( ) ;
537551 foreach ( var dir in Directory . EnumerateDirectories ( sessionsDir ) )
538552 {
539553 var ws = Path . Combine ( dir , "workspace.yaml" ) ;
540554 if ( ! File . Exists ( ws ) ) continue ;
541555 try
542556 {
543- string ? id = Path . GetFileName ( dir ) ;
557+ var id = Path . GetFileName ( dir ) ;
544558 string ? summary = null ;
545559 string ? cwd = null ;
546- DateTime updated = File . GetLastWriteTime ( ws ) ;
560+ var updated = File . GetLastWriteTime ( ws ) ;
547561 foreach ( var line in File . ReadLines ( ws ) )
548562 {
549563 var t = line . TrimStart ( ) ;
@@ -552,47 +566,87 @@ static void EnrichFromCopilotSessionStore(List<CaptureTab> tabs)
552566 else if ( t . StartsWith ( "cwd: " , StringComparison . Ordinal ) )
553567 cwd = t [ "cwd: " . Length ..] . Trim ( ) ;
554568 }
555- if ( ! string . IsNullOrEmpty ( id ) && ! string . IsNullOrEmpty ( summary ) && summary . Length >= 4 )
556- diskSessions . Add ( ( id , summary , cwd ?? "" , updated ) ) ;
569+ if ( ! string . IsNullOrEmpty ( id ) )
570+ diskSessions . Add ( ( id , summary ?? "" , cwd ?? "" , updated ) ) ;
557571 }
558572 catch ( IOException ) { }
559573 }
560574
561575 if ( diskSessions . Count == 0 ) return ;
562576
563- // Sort by most recent for tie-breaking
564- diskSessions = [ .. diskSessions . OrderByDescending ( s => s . Updated ) ] ;
565-
566577 foreach ( var tab in tabs )
567578 {
568579 if ( tab . CopilotSessionId != null ) continue ;
569- if ( string . IsNullOrEmpty ( tab . Title ) ) continue ;
570580
571- var cleanTitle = tab . Title . Replace ( "🤖" , "" , StringComparison . Ordinal ) . Trim ( ) ;
572- if ( cleanTitle . Length < 4 ) continue ;
581+ var cleanTitle = ( tab . Title ?? "" )
582+ . Replace ( "🤖" , "" , StringComparison . Ordinal ) . Trim ( ) ;
583+ var tabPath = tab . Path ?? "" ;
584+ var hasUsefulTitle = cleanTitle . Length >= 4 ;
585+ var hasUsefulPath = ! string . IsNullOrEmpty ( tabPath ) ;
573586
574- // Try exact summary match first, then bidirectional contains
575- var match = diskSessions . FirstOrDefault ( s =>
576- s . Summary . Equals ( cleanTitle , StringComparison . OrdinalIgnoreCase ) ) ;
577- if ( match . SessionId == null )
578- {
579- match = diskSessions . FirstOrDefault ( s =>
580- cleanTitle . Contains ( s . Summary , StringComparison . OrdinalIgnoreCase )
581- || s . Summary . Contains ( cleanTitle , StringComparison . OrdinalIgnoreCase ) ) ;
582- }
587+ if ( ! hasUsefulTitle && ! hasUsefulPath ) continue ;
583588
584- if ( match . SessionId != null )
589+ // Score every candidate session
590+ ( string SessionId , string Summary , string Cwd , DateTime Updated , double Score ) ? best = null ;
591+ foreach ( var s in diskSessions )
585592 {
586- tab . HasCopilot = true ;
587- tab . CopilotSessionId = match . SessionId ;
588- tab . CopilotSummary = match . Summary ;
589- if ( string . IsNullOrEmpty ( tab . Path ) && ! string . IsNullOrEmpty ( match . Cwd ) )
593+ if ( claimed . Contains ( s . SessionId ) ) continue ;
594+
595+ double score = 0 ;
596+
597+ // Path match (strongest signal)
598+ if ( hasUsefulPath && ! string . IsNullOrEmpty ( s . Cwd ) )
599+ {
600+ if ( tabPath . Equals ( s . Cwd , StringComparison . OrdinalIgnoreCase ) )
601+ score += 100 ;
602+ else if ( tabPath . StartsWith ( s . Cwd , StringComparison . OrdinalIgnoreCase )
603+ || s . Cwd . StartsWith ( tabPath , StringComparison . OrdinalIgnoreCase ) )
604+ score += 50 ;
605+ }
606+
607+ // Title/summary match
608+ if ( hasUsefulTitle && ! string . IsNullOrEmpty ( s . Summary ) )
590609 {
591- tab . Path = match . Cwd ;
592- tab . Folder = Path . GetFileName ( match . Cwd ) ;
593- tab . DirExists = Directory . Exists ( match . Cwd ) ;
610+ if ( cleanTitle . Equals ( s . Summary , StringComparison . OrdinalIgnoreCase ) )
611+ score += 80 ;
612+ else if ( cleanTitle . Contains ( s . Summary , StringComparison . OrdinalIgnoreCase )
613+ || s . Summary . Contains ( cleanTitle , StringComparison . OrdinalIgnoreCase ) )
614+ score += 40 ;
594615 }
616+
617+ if ( score == 0 ) continue ;
618+
619+ // Timestamp proximity bonus (closer to refTime = higher bonus, max +20)
620+ if ( refTime . HasValue )
621+ {
622+ var deltaHours = Math . Abs ( ( s . Updated - refTime . Value ) . TotalHours ) ;
623+ // 0h delta → +20, 24h → +10, 1week → 0
624+ score += Math . Max ( 0 , 20 - ( deltaHours * 20.0 / 168.0 ) ) ;
625+ }
626+ else
627+ {
628+ // No reference time — prefer most recently updated
629+ score += Math . Max ( 0 , 10 - ( DateTime . Now - s . Updated ) . TotalDays * 0.1 ) ;
630+ }
631+
632+ if ( best == null || score > best . Value . Score )
633+ best = ( s . SessionId , s . Summary , s . Cwd , s . Updated , score ) ;
634+ }
635+
636+ // Require minimum score to avoid garbage matches
637+ if ( best == null || best . Value . Score < 40 ) continue ;
638+
639+ tab . HasCopilot = true ;
640+ tab . CopilotSessionId = best . Value . SessionId ;
641+ if ( ! string . IsNullOrEmpty ( best . Value . Summary ) )
642+ tab . CopilotSummary = best . Value . Summary ;
643+ if ( string . IsNullOrEmpty ( tab . Path ) && ! string . IsNullOrEmpty ( best . Value . Cwd ) )
644+ {
645+ tab . Path = best . Value . Cwd ;
646+ tab . Folder = Path . GetFileName ( best . Value . Cwd ) ;
647+ tab . DirExists = Directory . Exists ( best . Value . Cwd ) ;
595648 }
649+ claimed . Add ( best . Value . SessionId ) ;
596650 }
597651 }
598652
@@ -618,7 +672,7 @@ ORDER BY w.closed_at DESC
618672 var firstSeen = reader . GetString ( 1 ) ;
619673 var lastSeen = reader . GetString ( 2 ) ;
620674 var closedAt = reader . IsDBNull ( 3 ) ? null : reader . GetString ( 3 ) ;
621- var tabs = GetLastKnownTabs ( id ) ;
675+ var tabs = GetLastKnownTabs ( id , closedAt ?? lastSeen ) ;
622676 if ( tabs . Count > 0 )
623677 result . Add ( new ClosedWindowRecord ( id , firstSeen , lastSeen , closedAt , tabs ) ) ;
624678 }
0 commit comments