diff --git a/PolyPilot.Tests/ImportCliSessionsTests.cs b/PolyPilot.Tests/ImportCliSessionsTests.cs new file mode 100644 index 0000000000..786420394e --- /dev/null +++ b/PolyPilot.Tests/ImportCliSessionsTests.cs @@ -0,0 +1,152 @@ +using PolyPilot.Services; + +namespace PolyPilot.Tests; + +/// +/// Tests for the CLI session import feature (ImportCliSessionsAsync). +/// +public class ImportCliSessionsTests +{ + // --- GenerateImportDisplayName --- + + [Fact] + public void GenerateImportDisplayName_PrefersSummary() + { + var name = CopilotService.GenerateImportDisplayName( + "Fix the login bug in auth module", + "/Users/test/projects/myapp", + "abc12345-1234-1234-1234-123456789abc"); + + Assert.Equal("Fix the login bug in auth module", name); + } + + [Fact] + public void GenerateImportDisplayName_TruncatesLongSummary() + { + var longSummary = new string('x', 80); + var name = CopilotService.GenerateImportDisplayName( + longSummary, "/some/path", "abc12345-dead-beef-1234-123456789abc"); + + Assert.Equal(50, name.Length); + Assert.EndsWith("...", name); + } + + [Fact] + public void GenerateImportDisplayName_FallsToCwdBasename() + { + var name = CopilotService.GenerateImportDisplayName( + null, "/Users/test/projects/my-cool-project", "abc12345-dead-beef-1234-123456789abc"); + + Assert.Equal("my-cool-project", name); + } + + [Fact] + public void GenerateImportDisplayName_FallsToCwdBasename_TrailingSlash() + { + var name = CopilotService.GenerateImportDisplayName( + null, "/Users/test/projects/my-cool-project/", "abc12345-dead-beef-1234-123456789abc"); + + Assert.Equal("my-cool-project", name); + } + + [Fact] + public void GenerateImportDisplayName_FallsToShortGuid() + { + var name = CopilotService.GenerateImportDisplayName( + null, null, "abc12345-dead-beef-1234-123456789abc"); + + Assert.Equal("abc12345", name); + } + + [Fact] + public void GenerateImportDisplayName_EmptySummaryFallsToCwd() + { + var name = CopilotService.GenerateImportDisplayName( + "", "/some/path/coolapp", "abc12345-dead-beef-1234-123456789abc"); + + Assert.Equal("coolapp", name); + } + + [Fact] + public void GenerateImportDisplayName_WhitespaceSummaryFallsToCwd() + { + var name = CopilotService.GenerateImportDisplayName( + " ", "/some/path/coolapp", "abc12345-dead-beef-1234-123456789abc"); + + Assert.Equal("coolapp", name); + } + + [Fact] + public void GenerateImportDisplayName_CleansNewlines() + { + var name = CopilotService.GenerateImportDisplayName( + "Fix the\nbug\r\nin module", null, "abc12345-dead-beef-1234-123456789abc"); + + Assert.Equal("Fix the bug in module", name); + } + + // --- ActiveSessionEntry.Imported flag --- + + [Fact] + public void ActiveSessionEntry_ImportedFlag_DefaultsFalse() + { + var entry = new ActiveSessionEntry { SessionId = "test", DisplayName = "Test" }; + Assert.False(entry.Imported); + } + + [Fact] + public void ActiveSessionEntry_ImportedFlag_RoundTripsViaJson() + { + var entry = new ActiveSessionEntry + { + SessionId = "test-id", + DisplayName = "Test Session", + Model = "claude-opus-4.6", + Imported = true + }; + + var json = System.Text.Json.JsonSerializer.Serialize(entry); + var deserialized = System.Text.Json.JsonSerializer.Deserialize(json); + + Assert.NotNull(deserialized); + Assert.True(deserialized!.Imported); + Assert.Equal("test-id", deserialized.SessionId); + } + + [Fact] + public void ActiveSessionEntry_ImportedFlag_FalseNotIncludedInJson() + { + var entry = new ActiveSessionEntry + { + SessionId = "test-id", + DisplayName = "Test Session", + Imported = false + }; + + var json = System.Text.Json.JsonSerializer.Serialize(entry); + // When false (default), the value should still serialize correctly + var deserialized = System.Text.Json.JsonSerializer.Deserialize(json); + Assert.NotNull(deserialized); + Assert.False(deserialized!.Imported); + } + + // --- Merge preserves Imported flag --- + + [Fact] + public void Merge_PreservesImportedFlag_FromPersistedEntries() + { + var active = new List(); + var persisted = new List + { + new() { SessionId = "imp-1", DisplayName = "Imported One", Model = "m", Imported = true } + }; + var closed = new HashSet(); + + var result = CopilotService.MergeSessionEntries( + active, persisted, closed, new HashSet(), _ => true); + + var importedEntry = result.FirstOrDefault(e => e.SessionId == "imp-1"); + Assert.NotNull(importedEntry); + Assert.True(importedEntry!.Imported); + } +} diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor b/PolyPilot/Components/Layout/SessionSidebar.razor index 19dc09bcf3..5e3439292b 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor +++ b/PolyPilot/Components/Layout/SessionSidebar.razor @@ -1166,6 +1166,23 @@ else + + @if (!isImporting) + { +
+ +
+ } + else + { +
+ ๐Ÿ“ฅ Importing... @importProgress +
+ } @foreach (var persisted in filteredPersistedSessions.Take(30)) { @@ -1338,6 +1355,8 @@ else private bool showDirectoryPicker; private List sessions = new(); private List persistedSessions = new(); + private bool isImporting = false; + private string importProgress = ""; // --- Status filter --- private enum SessionStatusFilter { All, Processing, NeedsAttention, Stuck, Idle } @@ -1765,6 +1784,38 @@ else if (showPersistedSessions) LoadPersistedSessions(); } + private async Task ImportAllCliSessions() + { + if (isImporting) return; + isImporting = true; + importProgress = "Scanning..."; + StateHasChanged(); + + try + { + var progress = new Progress<(int scanned, int imported, int total)>(p => + { + importProgress = $"{p.imported} imported ({p.scanned}/{p.total} scanned)"; + InvokeAsync(StateHasChanged); + }); + + var count = await Task.Run(() => CopilotService.ImportCliSessionsAsync(progress)); + importProgress = $"Done! {count} sessions imported."; + } + catch (Exception ex) + { + importProgress = $"Error: {ex.Message}"; + Console.WriteLine($"Import error: {ex}"); + } + finally + { + isImporting = false; + StateHasChanged(); + // Refresh the persisted session list since many are now "open" + LoadPersistedSessions(); + } + } + private void ToggleExternalSessions() => showExternalSessions = !showExternalSessions; private string? confirmExternalResumeId; // reused for error display only diff --git a/PolyPilot/Platforms/MacCatalyst/Info.plist b/PolyPilot/Platforms/MacCatalyst/Info.plist index 325cf53ebe..a9742abb2c 100644 --- a/PolyPilot/Platforms/MacCatalyst/Info.plist +++ b/PolyPilot/Platforms/MacCatalyst/Info.plist @@ -42,5 +42,7 @@ PolyPilot uses speech recognition to convert your voice into text messages. NSMicrophoneUsageDescription PolyPilot needs microphone access for speech-to-text input. + NSBluetoothAlwaysUsageDescription + PolyPilot does not use Bluetooth. diff --git a/PolyPilot/Platforms/iOS/Info.plist b/PolyPilot/Platforms/iOS/Info.plist index 81b4bc8a9d..528ec6b4ee 100644 --- a/PolyPilot/Platforms/iOS/Info.plist +++ b/PolyPilot/Platforms/iOS/Info.plist @@ -36,6 +36,8 @@ PolyPilot uses speech recognition to convert your voice into text messages. NSMicrophoneUsageDescription PolyPilot needs microphone access for speech-to-text input. + NSBluetoothAlwaysUsageDescription + PolyPilot does not use Bluetooth. UIViewControllerBasedStatusBarAppearance diff --git a/PolyPilot/PolyPilot.csproj b/PolyPilot/PolyPilot.csproj index 278ba755bb..703dfc85e4 100644 --- a/PolyPilot/PolyPilot.csproj +++ b/PolyPilot/PolyPilot.csproj @@ -28,6 +28,7 @@ (reverts to defaults: Runtime for Debug, XamlC for Release) (force runtime inflation) --> SourceGen + false PolyPilot diff --git a/PolyPilot/Services/CopilotService.Persistence.cs b/PolyPilot/Services/CopilotService.Persistence.cs index 76358a3506..5cc4548e7c 100644 --- a/PolyPilot/Services/CopilotService.Persistence.cs +++ b/PolyPilot/Services/CopilotService.Persistence.cs @@ -46,6 +46,7 @@ private void SaveActiveSessionsToDisk() WorkingDirectory = s.Info.WorkingDirectory, GroupId = sessionMetas.FirstOrDefault(m => m.SessionName == s.Info.Name)?.GroupId, RecoveredFromSessionId = s.Info.RecoveredFromSessionId, + Imported = s.IsImported, LastPrompt = s.Info.IsProcessing ? s.Info.History.LastOrDefault(m => m.IsUser)?.Content : null, @@ -94,6 +95,7 @@ private void SaveActiveSessionsToDiskCore() ReasoningEffort = s.Info.ReasoningEffort, GroupId = sessionMetas.FirstOrDefault(m => m.SessionName == s.Info.Name)?.GroupId, RecoveredFromSessionId = s.Info.RecoveredFromSessionId, + Imported = s.IsImported, LastPrompt = s.Info.IsProcessing ? s.Info.History.LastOrDefault(m => m.IsUser)?.Content : null, @@ -424,6 +426,24 @@ private async Task EnsureSessionConnectedAsync(string sessionName, SessionState Debug($"Lazy-resuming session '{sessionName}' (id={sessionId})..."); + // Load history for imported sessions before SDK resume โ€” they were restored + // without history to keep startup fast. + if (state.IsImported && state.Info.History.Count <= 1) + { + Debug($"Loading history for imported session '{sessionName}' before SDK resume..."); + var (importHistory, importFromDb) = await LoadBestHistoryAsync(sessionId); + state.Info.History.Clear(); + foreach (var msg in importHistory) + state.Info.History.Add(msg); + foreach (var msg in state.Info.History.Where(m => + (m.MessageType == ChatMessageType.ToolCall || m.MessageType == ChatMessageType.Reasoning) && !m.IsComplete)) + msg.IsComplete = true; + state.Info.MessageCount = state.Info.History.Count; + state.Info.LastReadMessageCount = state.Info.History.Count; + if (importHistory.Count > 0 && !importFromDb) + await _chatDb.BulkInsertAsync(sessionId, importHistory); + } + // Use snapshot for thread safety โ€” may be called from ThreadPool via SendPromptAsync var groupId = SnapshotSessionMetas().FirstOrDefault(m => m.SessionName == sessionName)?.GroupId; var resumeModel = state.Info.Model ?? DefaultModel; @@ -896,6 +916,34 @@ public async Task RestorePreviousSessionsAsync(CancellationToken cancellationTok continue; } + // Imported sessions (from CLI bulk import): create ultra-lightweight + // placeholder with no history loading. History loads lazily when the + // user first opens the session. This keeps startup fast even with + // thousands of imported sessions. + if (entry.Imported) + { + var importModel = Models.ModelHelper.NormalizeToSlug(entry.Model ?? DefaultModel); + if (string.IsNullOrEmpty(importModel)) importModel = DefaultModel; + var importInfo = new AgentSessionInfo + { + Name = entry.DisplayName, + Model = importModel, + CreatedAt = entry.CreatedAt ?? DateTimeOffset.UtcNow, + SessionId = entry.SessionId, + WorkingDirectory = entry.WorkingDirectory + }; + importInfo.LastUpdatedAt = entry.LastUpdatedAt ?? DateTime.Now; + importInfo.GitBranch = GetGitBranch(importInfo.WorkingDirectory); + // Placeholder message so session isn't empty in UI + importInfo.History.Add(ChatMessage.SystemMessage("๐Ÿ“ฅ Imported CLI session ยท Open to load conversation history")); + importInfo.MessageCount = 1; + importInfo.LastReadMessageCount = 1; + var importState = new SessionState { Session = null!, Info = importInfo }; + importState.IsImported = true; + _sessions[entry.DisplayName] = importState; + continue; // Don't set _activeSessionName for imported sessions + } + // Create lightweight placeholder โ€” actual SDK resume happens lazily // when user sends a message (EnsureSessionConnectedAsync). // This avoids 41 sequential SDK connections blocking app startup. @@ -1523,6 +1571,247 @@ public void SetSessionAlias(string sessionId, string alias) catch { } } + /// + /// Import all CLI sessions from ~/.copilot/session-state into PolyPilot. + /// Reads only workspace.yaml (lightweight โ€” no events.jsonl loading). + /// History loads lazily when the user first opens the session. + /// Returns the number of sessions imported. + /// + public async Task ImportCliSessionsAsync(IProgress<(int scanned, int imported, int total)>? progress = null, CancellationToken cancellationToken = default) + { + if (!Directory.Exists(SessionStatePath)) + return 0; + + // Collect session IDs already tracked by PolyPilot + var ownedSessionIds = new HashSet( + _sessions.Values + .Where(s => !string.IsNullOrEmpty(s.Info.SessionId)) + .Select(s => s.Info.SessionId!), + StringComparer.OrdinalIgnoreCase); + + // Also check closed sessions to avoid reimporting + foreach (var closedId in _closedSessionIds.Keys) + ownedSessionIds.Add(closedId); + + string[] dirs; + try + { + dirs = Directory.GetDirectories(SessionStatePath); + } + catch { return 0; } + + // Create or get the "Imported from CLI" group + const string importedGroupName = "Imported from CLI"; + var importedGroup = Organization.Groups.FirstOrDefault(g => g.Name == importedGroupName); + if (importedGroup == null) + { + importedGroup = CreateGroup(importedGroupName); + } + + var imported = 0; + var scanned = 0; + var total = dirs.Length; + // Track display names we've used to avoid collisions within this import batch + var usedNames = new HashSet( + _sessions.Keys, + StringComparer.OrdinalIgnoreCase); + + foreach (var dir in dirs) + { + cancellationToken.ThrowIfCancellationRequested(); + + scanned++; + if (scanned % 100 == 0) + progress?.Report((scanned, imported, total)); + + var dirName = Path.GetFileName(dir); + if (!Guid.TryParse(dirName, out _)) continue; + + // Skip sessions already in PolyPilot + if (ownedSessionIds.Contains(dirName)) continue; + + var workspaceFile = Path.Combine(dir, "workspace.yaml"); + var eventsFile = Path.Combine(dir, "events.jsonl"); + if (!File.Exists(workspaceFile) || !File.Exists(eventsFile)) continue; + + // Parse workspace.yaml for lightweight metadata + string? sessionId = null; + string? cwd = null; + string? summary = null; + DateTimeOffset? createdAt = null; + DateTimeOffset? updatedAt = null; + + try + { + foreach (var line in File.ReadLines(workspaceFile).Take(20)) + { + if (line.StartsWith("id:", StringComparison.OrdinalIgnoreCase)) + sessionId = line["id:".Length..].Trim().Trim('"', '\''); + else if (line.StartsWith("cwd:", StringComparison.OrdinalIgnoreCase)) + cwd = line["cwd:".Length..].Trim().Trim('"', '\''); + else if (line.StartsWith("summary:", StringComparison.OrdinalIgnoreCase)) + { + var summaryText = line["summary:".Length..].Trim().Trim('"', '\''); + if (!string.IsNullOrEmpty(summaryText)) + summary = summaryText; + } + else if (line.StartsWith("created_at:", StringComparison.OrdinalIgnoreCase)) + { + var val = line["created_at:".Length..].Trim().Trim('"', '\''); + if (DateTimeOffset.TryParse(val, out var ca)) + createdAt = ca; + } + else if (line.StartsWith("updated_at:", StringComparison.OrdinalIgnoreCase)) + { + var val = line["updated_at:".Length..].Trim().Trim('"', '\''); + if (DateTimeOffset.TryParse(val, out var ua)) + updatedAt = ua; + } + } + } + catch { continue; } + + // Validate session ID matches directory name + if (string.IsNullOrEmpty(sessionId) || !string.Equals(sessionId, dirName, StringComparison.OrdinalIgnoreCase)) + continue; + + // Skip sessions with empty events (no meaningful content) + try + { + var eventsFileInfo = new FileInfo(eventsFile); + if (eventsFileInfo.Length < 50) continue; // Too small to be meaningful + } + catch { continue; } + + // Generate display name: summary > cwd basename > short guid + var baseName = GenerateImportDisplayName(summary, cwd, sessionId); + var displayName = baseName; + + // Dedup: add suffix if name is already taken + if (usedNames.Contains(displayName)) + { + for (int i = 2; i <= 9999; i++) + { + var candidate = $"{baseName} ({i})"; + if (!usedNames.Contains(candidate)) + { + displayName = candidate; + break; + } + } + } + usedNames.Add(displayName); + + // Create lightweight placeholder with no history + var info = new AgentSessionInfo + { + Name = displayName, + SessionId = sessionId, + Model = DefaultModel, + WorkingDirectory = cwd, + CreatedAt = createdAt ?? DateTimeOffset.UtcNow, + }; + info.LastUpdatedAt = (updatedAt ?? createdAt)?.LocalDateTime ?? DateTime.Now; + info.GitBranch = GetGitBranch(cwd); + + // Add a placeholder message so the session isn't completely empty in the UI + info.History.Add(ChatMessage.SystemMessage( + summary != null + ? $"๐Ÿ“ฅ Imported CLI session ยท {summary}" + : "๐Ÿ“ฅ Imported CLI session ยท Open to load conversation history")); + info.MessageCount = 1; + info.LastReadMessageCount = 1; + + var state = new SessionState { Session = null!, Info = info }; + state.IsImported = true; + + _sessions[displayName] = state; + ownedSessionIds.Add(sessionId); + + // Add to the imported group + AddSessionMeta(new SessionMeta + { + SessionName = displayName, + GroupId = importedGroup.Id, + }); + + imported++; + + // Yield every 500 sessions to avoid blocking the thread too long + if (imported % 500 == 0) + await Task.Yield(); + } + + progress?.Report((scanned, imported, total)); + + if (imported > 0) + { + Debug($"Imported {imported} CLI sessions into PolyPilot"); + FlushSaveActiveSessionsToDisk(); + FlushSaveOrganization(); + OnStateChanged?.Invoke(); + } + + return imported; + } + + /// + /// Generate a display name for an imported session from available metadata. + /// + internal static string GenerateImportDisplayName(string? summary, string? cwd, string sessionId) + { + // Prefer summary (truncated to 50 chars) + if (!string.IsNullOrWhiteSpace(summary)) + { + var clean = summary.Replace("\n", " ").Replace("\r", "").Trim(); + return clean.Length > 50 ? clean[..47] + "..." : clean; + } + + // Fall back to cwd basename + if (!string.IsNullOrWhiteSpace(cwd)) + { + var basename = Path.GetFileName(cwd.TrimEnd('/', '\\')); + if (!string.IsNullOrEmpty(basename)) + return basename; + } + + // Last resort: short GUID + return sessionId.Length >= 8 ? sessionId[..8] : sessionId; + } + + /// + /// Loads history for an imported session that hasn't had its history loaded yet. + /// Called when the user first selects an imported session. + /// + public async Task LoadImportedSessionHistoryAsync(string sessionName) + { + if (!_sessions.TryGetValue(sessionName, out var state)) return; + if (!state.IsImported) return; + 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; + + var (history, _) = await LoadBestHistoryAsync(state.Info.SessionId); + + // Replace the placeholder with real history + InvokeOnUI(() => + { + state.Info.History.Clear(); + foreach (var msg in history) + state.Info.History.Add(msg); + + // Mark stale incomplete tool calls/reasoning as complete + foreach (var msg in state.Info.History.Where(m => + (m.MessageType == ChatMessageType.ToolCall || m.MessageType == ChatMessageType.Reasoning) && !m.IsComplete)) + msg.IsComplete = true; + + state.Info.MessageCount = state.Info.History.Count; + state.Info.LastReadMessageCount = state.Info.History.Count; + NotifyStateChanged(); + }); + } + /// /// Gets a list of persisted session GUIDs from ~/.copilot/session-state /// diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 57840e5f44..1b51970fda 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -796,6 +796,12 @@ private class SessionState /// clears HasDeferredIdle โ€” the two fields are an inseparable companion pair. /// public long SubagentDeferStartedAtTicks; + /// + /// True for sessions imported from CLI via "Import CLI Sessions". + /// History is not loaded during restore โ€” it loads lazily on first select. + /// Once history is loaded, this stays true to preserve the Imported flag in saves. + /// + public bool IsImported; } private static void DisposePrematureIdleSignal(SessionState? state) @@ -4957,6 +4963,13 @@ public bool SwitchSession(string name) _ = _bridgeClient.SwitchSessionAsync(name) .ContinueWith(t => Console.WriteLine($"[CopilotService] SwitchSession bridge error: {t.Exception?.InnerException?.Message}"), TaskContinuationOptions.OnlyOnFaulted); + + // Lazy-load history for imported sessions on first select + if (_sessions.TryGetValue(name, out var state) && state.IsImported && state.Info.History.Count <= 1) + { + _ = LoadImportedSessionHistoryAsync(name); + } + ClearPendingCompletions(); OnStateChanged?.Invoke(); return true; @@ -5467,6 +5480,12 @@ public class ActiveSessionEntry public string? LastPrompt { get; set; } public string? GroupId { get; set; } public string? RecoveredFromSessionId { get; set; } + /// + /// True for sessions imported from the CLI via "Import CLI Sessions". + /// Imported sessions skip history loading on restore โ€” history is loaded + /// lazily when the user first opens the session. + /// + public bool Imported { get; set; } // Usage stats persisted across reconnects public int TotalInputTokens { get; set; } public int TotalOutputTokens { get; set; }