Conversation
Add bulk import feature to bring all sessions from ~/.copilot/session-state/ into PolyPilot's session management: - ImportCliSessionsAsync() scans workspace.yaml files for lightweight metadata (no expensive events.jsonl parsing) and creates placeholder sessions - Imported sessions use lazy history loading: events.jsonl is only parsed when the user opens the session (SwitchSession) or sends a message (EnsureSessionConnectedAsync) - Sessions grouped into 'Imported from CLI' group with deduped display names - IsImported flag on SessionState + Imported property on ActiveSessionEntry persists across app restarts - RestorePreviousSessionsAsync fast-path skips LoadBestHistoryAsync for imported sessions to keep startup performance unchanged - UI: '📥 Import All CLI Sessions' button in Saved Sessions section with progress reporting Also fixes: - Skip Xcode version validation (ValidateXcodeVersion=false in csproj) - Add NSBluetoothAlwaysUsageDescription to MacCatalyst/iOS Info.plist (required by Xcode 26.4's CoreBluetooth TCC check at launch) Tests: 12 new tests for GenerateImportDisplayName, ActiveSessionEntry Imported flag serialization, and merge behavior. All 3682 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Expert Code Review: 8 findings (1 critical, 4 moderate, 3 minor) posted as inline comments and summary. See summary comment below for full details including overflow findings outside the diff.
Generated by Expert Code Review (auto) for issue #825 · ● 31.6M
|
|
||
| // Create or get the "Imported from CLI" group | ||
| const string importedGroupName = "Imported from CLI"; | ||
| var importedGroup = Organization.Groups.FirstOrDefault(g => g.Name == importedGroupName); |
There was a problem hiding this comment.
🔴 CRITICAL — Thread-unsafe Organization.Groups access from ThreadPool (3/3 reviewers)
ImportCliSessionsAsync runs on a ThreadPool thread (via Task.Run in SessionSidebar.razor), but reads Organization.Groups (a plain List<T>) without holding _organizationLock. The class invariant at CopilotService.cs:488 states: "ALL reads and writes to these lists MUST hold _organizationLock when accessed from background threads."
Additionally, CreateGroup() and OnStateChanged?.Invoke() (line 1752) fire from the ThreadPool — Blazor components subscribing to OnStateChanged expect UI-thread context.
Scenario: User clicks "Import All CLI Sessions" while the UI thread is iterating Organization.Groups (e.g., rendering the sidebar). → InvalidOperationException: Collection was modified or corrupt list state.
Fix: Use SnapshotGroups() for the read. Marshal CreateGroup and OnStateChanged calls to the UI thread via InvokeOnUI().
| if (string.IsNullOrEmpty(state.Info.SessionId)) return; | ||
|
|
||
| // Check if history is already loaded (more than just the placeholder) | ||
| if (state.Info.History.Count > 1) return; |
There was a problem hiding this comment.
🟢 MINOR — Empty history causes repeated disk I/O on every click (2/3 reviewers after follow-up)
If LoadBestHistoryAsync returns an empty list (unparseable events.jsonl), History.Clear() leaves Count = 0. The > 1 guard won't prevent re-loading on the next SwitchSession call, causing repeated disk parsing on every click of this session.
Fix: Set a state.HistoryLoaded = true flag after the first load attempt, checked before History.Count:
if (state.HistoryLoaded || state.Info.History.Count > 1) return;
state.HistoryLoaded = true;| for (int i = 2; i <= 9999; i++) | ||
| { | ||
| var candidate = $"{baseName} ({i})"; | ||
| if (!usedNames.Contains(candidate)) | ||
| { | ||
| displayName = candidate; | ||
| break; | ||
| } |
There was a problem hiding this comment.
🟢 MINOR — Dedup loop exhaustion silently overwrites existing session (2/3 reviewers)
If all 9998 candidates (baseName (2) through baseName (9999)) are taken, the loop exits without updating displayName. It stays equal to baseName, which is already in usedNames. Then _sessions[displayName] = state silently replaces the existing session in memory — data loss.
Unlikely with typical usage but theoretically reachable with 10K+ sessions sharing a cwd basename.
Fix: After the loop, check if a unique name was found:
if (usedNames.Contains(displayName))
continue; // skip rather than overwrite| var (importHistory, importFromDb) = await LoadBestHistoryAsync(sessionId); | ||
| state.Info.History.Clear(); | ||
| foreach (var msg in importHistory) |
There was a problem hiding this comment.
🟡 MODERATE — History mutation off-UI-thread (3/3 reviewers after follow-up)
EnsureSessionConnectedAsync runs from Task.Run (via SendPromptAsync), but this block clears and repopulates state.Info.History — a List<ChatMessage> — directly on the background thread. The parallel method LoadImportedSessionHistoryAsync (line 1798) correctly wraps the identical operation in InvokeOnUI().
Scenario: User opens an imported session and sends a message. The Blazor renderer is enumerating History for display while this code clears/repopulates it. → InvalidOperationException: Collection was modified during enumeration.
Fix: Wrap lines 434–443 in InvokeOnUI(() => { ... }), matching the pattern in LoadImportedSessionHistoryAsync.
| if (_sessions.TryGetValue(name, out var state) && state.IsImported && state.Info.History.Count <= 1) | ||
| { | ||
| _ = LoadImportedSessionHistoryAsync(name); |
There was a problem hiding this comment.
🟡 MODERATE — Race condition: dual history loading paths (2/3 reviewers)
SwitchSession fire-and-forgets LoadImportedSessionHistoryAsync here. If the user immediately sends a message, EnsureSessionConnectedAsync also checks state.IsImported && History.Count <= 1 (line 431) and may concurrently load history. Both paths can read History.Count <= 1 before either completes, causing:
- Duplicate
LoadBestHistoryAsynccalls (double disk I/O) - Duplicate
_chatDb.BulkInsertAsync(from theEnsureSessionConnectedAsyncpath) - History cleared/repopulated twice concurrently
Fix: Use a per-session flag (e.g., IsHistoryLoading) with Interlocked.CompareExchange to ensure only one load executes. Or remove the SwitchSession eager load and rely solely on EnsureSessionConnectedAsync.
🟢 MINOR — Fire-and-forget swallows exceptions (2/3 reviewers)
Unlike the bridge call above (line 4963) which has .ContinueWith(OnlyOnFaulted), this discards the task entirely. Disk errors or corrupt events.jsonl will be silently swallowed with no user feedback or diagnostic logging.
Fix: Add .ContinueWith(t => Debug($"[IMPORT] History load failed: {t.Exception?.InnerException?.Message}"), TaskContinuationOptions.OnlyOnFaulted).
| // Check if history is already loaded (more than just the placeholder) | ||
| if (state.Info.History.Count > 1) return; | ||
|
|
||
| var (history, _) = await LoadBestHistoryAsync(state.Info.SessionId); |
There was a problem hiding this comment.
🟡 MODERATE — Missing BulkInsertAsync — history never cached in DB (3/3 reviewers after follow-up)
The fromDb flag is discarded (var (history, _)), so history loaded via SwitchSession → LoadImportedSessionHistoryAsync is never inserted into _chatDb. In contrast, EnsureSessionConnectedAsync (line 442) correctly calls _chatDb.BulkInsertAsync when !importFromDb.
Scenario: User clicks an imported session; history loads from events.jsonl. If the SwitchSession path wins the race, the DB is never populated and subsequent operations that read from the DB cache will miss this history.
Fix:
var (history, fromDb) = await LoadBestHistoryAsync(state.Info.SessionId);
// ... (inside InvokeOnUI, after populating History) ...
if (history.Count > 0 && !fromDb)
await _chatDb.BulkInsertAsync(state.Info.SessionId, history);
Summary
Adds a bulk import feature to bring all sessions from
~/.copilot/session-state/into PolyPilot's session management. This addresses the gap where the GitHub CLI app creates sessions that PolyPilot doesn't know about.Changes
Import Engine (
CopilotService.Persistence.cs)ImportCliSessionsAsync()— Scans all session directories, reads onlyworkspace.yaml(fast, no events.jsonl parsing), creates lightweight placeholder sessionsGenerateImportDisplayName()— Derives display names from summary → cwd basename → short GUID, with dedup suffixesLoadImportedSessionHistoryAsync()— Lazy history loader, only reads events.jsonl when user actually opens the sessionPerformance Design
LoadBestHistoryAsync()duringRestorePreviousSessionsAsync— startup time unchangedSwitchSession()(user clicks) andEnsureSessionConnectedAsync()(user sends message)Data Model
SessionState.IsImportedflag tracks imported sessionsActiveSessionEntry.Importedpersists the flag across restartsUI (
SessionSidebar.razor)Build Fixes (bonus)
ValidateXcodeVersion=false)NSBluetoothAlwaysUsageDescriptionto MacCatalyst/iOS plists (Xcode 26.4 requirement)Testing
ImportCliSessionsTests.cs