diff --git a/PolyPilot.Tests/CloseSessionIconTests.cs b/PolyPilot.Tests/CloseSessionIconTests.cs index e91964af05..908dc53c73 100644 --- a/PolyPilot.Tests/CloseSessionIconTests.cs +++ b/PolyPilot.Tests/CloseSessionIconTests.cs @@ -3,8 +3,9 @@ namespace PolyPilot.Tests; /// -/// Ensures the "Close Session" button uses a non-destructive icon (not trash/wastebasket). -/// The trash icon (πŸ—‘) implies permanent deletion, but closing a session is reversible. +/// Ensures the "Close Session" button has an appropriate icon. +/// SessionCard uses βœ• (non-destructive inline close). +/// SessionListItem uses πŸ—‘ (opens a dialog with destructive options like delete worktree/branch). /// public class CloseSessionIconTests { @@ -22,17 +23,19 @@ public void SessionCard_CloseButton_DoesNotUseTrashIcon() var file = Path.Combine(GetRepoRoot(), "PolyPilot", "Components", "SessionCard.razor"); var content = File.ReadAllText(file); - // The close session button must not use the trash/wastebasket emoji + // SessionCard close is a simple inline close β€” no destructive options Assert.DoesNotContain("πŸ—‘", content.Substring(content.IndexOf("Close Session") - 5, 10)); } [Fact] - public void SessionListItem_CloseButton_DoesNotUseTrashIcon() + public void SessionListItem_CloseButton_UsesTrashIcon() { + // SessionListItem's close opens a dialog with destructive options + // (delete worktree, delete branch) so the trash icon is appropriate. var file = Path.Combine(GetRepoRoot(), "PolyPilot", "Components", "Layout", "SessionListItem.razor"); var content = File.ReadAllText(file); - Assert.DoesNotContain("πŸ—‘", content.Substring(content.IndexOf("Close Session") - 5, 10)); + Assert.Contains("πŸ—‘ Close Session", content); } [Fact] @@ -45,11 +48,13 @@ public void SessionCard_CloseButton_UsesCloseIcon() } [Fact] - public void SessionListItem_CloseButton_UsesCloseIcon() + public void SessionListItem_CloseButton_IsDestructiveStyle() { + // The close menu button should be marked as destructive since it can delete worktrees/branches var file = Path.Combine(GetRepoRoot(), "PolyPilot", "Components", "Layout", "SessionListItem.razor"); var content = File.ReadAllText(file); - Assert.Contains("βœ• Close Session", content); + // Find the menu button line (contains πŸ—‘ Close Session) + Assert.Contains("class=\"menu-item destructive\" @onclick=\"ShowCloseConfirm\"", content); } } diff --git a/PolyPilot.Tests/InputSelectionTests.cs b/PolyPilot.Tests/InputSelectionTests.cs index 5f78f5e41f..9c84aca9df 100644 --- a/PolyPilot.Tests/InputSelectionTests.cs +++ b/PolyPilot.Tests/InputSelectionTests.cs @@ -71,7 +71,6 @@ public void ValueBoundInputs_MustNotUse_OnKeyDown() /// [Theory] [InlineData("Layout/CreateSessionForm.razor", "ns-name", "@onkeyup")] - [InlineData("Layout/CreateSessionForm.razor", "wt-branch-input", "@onkeyup")] [InlineData("Layout/SessionListItem.razor", "rename-input", "@onkeyup")] [InlineData("SessionCard.razor", "card-rename-input", "@onkeyup")] public void SpecificInputs_UseOnKeyUp(string relativePath, string cssClass, string expectedEvent) diff --git a/PolyPilot.Tests/RepoManagerTests.cs b/PolyPilot.Tests/RepoManagerTests.cs index 2927bc4fb7..4eb3f6083a 100644 --- a/PolyPilot.Tests/RepoManagerTests.cs +++ b/PolyPilot.Tests/RepoManagerTests.cs @@ -1,3 +1,4 @@ +using PolyPilot.Models; using PolyPilot.Services; namespace PolyPilot.Tests; @@ -55,4 +56,134 @@ public void NormalizeRepoUrl_NonShorthand_PassesThrough(string input) { Assert.Equal(input, RepoManager.NormalizeRepoUrl(input)); } + + #region Save Guard Tests (Review Finding #9) + + private static readonly System.Reflection.BindingFlags NonPublic = + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; + + private static void SetField(object obj, string name, object value) + { + var field = obj.GetType().GetField(name, NonPublic)!; + field.SetValue(obj, value); + } + + private static T GetField(object obj, string name) + { + var field = obj.GetType().GetField(name, NonPublic)!; + return (T)field.GetValue(obj)!; + } + + private static void InvokeSave(RepoManager rm) + { + var method = typeof(RepoManager).GetMethod("Save", NonPublic)!; + method.Invoke(rm, null); + } + + [Fact] + public void Save_AfterFailedLoad_DoesNotOverwriteWithEmptyState() + { + var rm = new RepoManager(); + var tempDir = Path.Combine(Path.GetTempPath(), $"repomgr-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + var stateFile = Path.Combine(tempDir, "repos.json"); + + try + { + // Write valid state to file + var validJson = """{"Repositories":[{"Id":"test-1","Name":"TestRepo","Url":"https://example.com","BareClonePath":"","AddedAt":"2026-01-01T00:00:00Z"}],"Worktrees":[]}"""; + File.WriteAllText(stateFile, validJson); + + // Simulate failed load: _loaded=true, _loadedSuccessfully=false, empty state + SetField(rm, "_loaded", true); + SetField(rm, "_loadedSuccessfully", false); + SetField(rm, "_state", new RepositoryState()); + + // Override StateFile to our temp path + var stateFileField = typeof(RepoManager).GetField("_stateFile", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!; + var originalValue = stateFileField.GetValue(null); + stateFileField.SetValue(null, stateFile); + try + { + // Save should be blocked β€” empty state after failed load + InvokeSave(rm); + + // Original file should still have our repo + var content = File.ReadAllText(stateFile); + Assert.Contains("test-1", content); + } + finally + { + stateFileField.SetValue(null, originalValue); + } + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void Save_AfterSuccessfulLoad_PersistsEmptyState() + { + var rm = new RepoManager(); + var tempDir = Path.Combine(Path.GetTempPath(), $"repomgr-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + var stateFile = Path.Combine(tempDir, "repos.json"); + + try + { + // Simulate successful load then all repos removed + SetField(rm, "_loaded", true); + SetField(rm, "_loadedSuccessfully", true); + SetField(rm, "_state", new RepositoryState()); + + var stateFileField = typeof(RepoManager).GetField("_stateFile", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!; + var originalValue = stateFileField.GetValue(null); + stateFileField.SetValue(null, stateFile); + try + { + // Save should proceed β€” load was successful, intentional empty state + InvokeSave(rm); + + var content = File.ReadAllText(stateFile); + Assert.Contains("Repositories", content); + Assert.DoesNotContain("test-1", content); + } + finally + { + stateFileField.SetValue(null, originalValue); + } + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void Repositories_ReturnsCopy_ThreadSafe() + { + var rm = new RepoManager(); + // Inject state with some repos + SetField(rm, "_loaded", true); + SetField(rm, "_loadedSuccessfully", true); + var state = new RepositoryState + { + Repositories = new() { new() { Id = "r1", Name = "R1" }, new() { Id = "r2", Name = "R2" } } + }; + SetField(rm, "_state", state); + + // Get a snapshot + var repos = rm.Repositories; + Assert.Equal(2, repos.Count); + + // Mutate the underlying state + state.Repositories.RemoveAll(r => r.Id == "r1"); + + // Snapshot should be unaffected (it's a copy) + Assert.Equal(2, repos.Count); + } + + #endregion } diff --git a/PolyPilot.Tests/WorktreeStrategyTests.cs b/PolyPilot.Tests/WorktreeStrategyTests.cs new file mode 100644 index 0000000000..6b38cfa9de --- /dev/null +++ b/PolyPilot.Tests/WorktreeStrategyTests.cs @@ -0,0 +1,694 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using PolyPilot.Models; +using PolyPilot.Services; + +namespace PolyPilot.Tests; + +/// +/// Tests for per-worker worktree isolation strategies in multi-agent groups. +/// Validates that CreateGroupFromPresetAsync creates the correct number of +/// worktrees with unique paths per strategy, and that session metadata is +/// correctly wired up. +/// +public class WorktreeStrategyTests +{ + private readonly StubChatDatabase _chatDb = new(); + private readonly StubServerManager _serverManager = new(); + private readonly StubWsBridgeClient _bridgeClient = new(); + private readonly StubDemoService _demoService = new(); + private readonly IServiceProvider _serviceProvider; + + public WorktreeStrategyTests() + { + var services = new ServiceCollection(); + _serviceProvider = services.BuildServiceProvider(); + } + + /// + /// A FakeRepoManager that doesn't touch git β€” returns fake worktrees + /// with unique IDs and paths, tracking all creation calls. + /// + private class FakeRepoManager : RepoManager + { + public List<(string RepoId, string BranchName, bool SkipFetch)> CreateCalls { get; } = new(); + public int FetchCallCount { get; private set; } + private int _worktreeCounter; + + public FakeRepoManager(List repos) + { + // Inject state via reflection (same pattern as existing tests) + var stateField = typeof(RepoManager).GetField("_state", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + var loadedField = typeof(RepoManager).GetField("_loaded", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + stateField.SetValue(this, new RepositoryState { Repositories = repos, Worktrees = new() }); + loadedField.SetValue(this, true); + } + + public override Task CreateWorktreeAsync(string repoId, string branchName, + string? baseBranch = null, bool skipFetch = false, CancellationToken ct = default) + { + CreateCalls.Add((repoId, branchName, skipFetch)); + var id = $"wt-{Interlocked.Increment(ref _worktreeCounter)}"; + var wt = new WorktreeInfo + { + Id = id, + RepoId = repoId, + Branch = branchName, + Path = $"/fake/worktrees/{id}" + }; + return Task.FromResult(wt); + } + + public override Task FetchAsync(string repoId, CancellationToken ct = default) + { + FetchCallCount++; + return Task.CompletedTask; + } + } + + private CopilotService CreateDemoService(RepoManager rm) + { + var svc = new CopilotService(_chatDb, _serverManager, _bridgeClient, rm, _serviceProvider, _demoService); + // Enable demo mode so CreateSessionAsync works without a real Copilot client + var prop = typeof(CopilotService).GetProperty("IsDemoMode")!; + prop.SetValue(svc, true); + return svc; + } + + private static GroupPreset MakePreset(int workerCount, WorktreeStrategy? defaultStrategy = null) + { + return new GroupPreset( + "TestTeam", "Test", "πŸ§ͺ", MultiAgentMode.Orchestrator, + "claude-opus-4.6", Enumerable.Repeat("claude-sonnet-4.6", workerCount).ToArray()) + { + DefaultWorktreeStrategy = defaultStrategy + }; + } + + #region WorktreeStrategy Enum Serialization + + [Fact] + public void WorktreeStrategy_AllValues_RoundTrip() + { + foreach (var strategy in Enum.GetValues()) + { + var group = new SessionGroup + { + Id = $"test-{strategy}", + Name = $"Test {strategy}", + IsMultiAgent = true, + WorktreeStrategy = strategy + }; + + var json = JsonSerializer.Serialize(group); + var restored = JsonSerializer.Deserialize(json)!; + + Assert.Equal(strategy, restored.WorktreeStrategy); + } + } + + [Fact] + public void WorktreeStrategy_DefaultsToShared() + { + var group = new SessionGroup { Id = "x", Name = "X" }; + Assert.Equal(WorktreeStrategy.Shared, group.WorktreeStrategy); + } + + [Fact] + public void GroupPreset_DefaultWorktreeStrategy_Nullable() + { + var preset = new GroupPreset("T", "D", "E", MultiAgentMode.Broadcast, "m", new[] { "w" }); + Assert.Null(preset.DefaultWorktreeStrategy); + + var presetWithStrategy = new GroupPreset("T", "D", "E", MultiAgentMode.Broadcast, "m", new[] { "w" }) + { + DefaultWorktreeStrategy = WorktreeStrategy.FullyIsolated + }; + Assert.Equal(WorktreeStrategy.FullyIsolated, presetWithStrategy.DefaultWorktreeStrategy); + } + + [Fact] + public void PRReviewSquad_DefaultsToFullyIsolated() + { + var prSquad = GroupPreset.BuiltIn.First(p => p.Name == "PR Review Squad"); + Assert.Equal(WorktreeStrategy.FullyIsolated, prSquad.DefaultWorktreeStrategy); + } + + #endregion + + #region FullyIsolated Strategy + + [Fact] + public async Task FullyIsolated_CreatesUniqueWorktreePerSession() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(3, WorktreeStrategy.FullyIsolated); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1"); + + Assert.NotNull(group); + Assert.Equal(WorktreeStrategy.FullyIsolated, group!.WorktreeStrategy); + + // 1 orchestrator + 3 workers = 4 worktrees + Assert.Equal(4, rm.CreateCalls.Count); + } + + [Fact] + public async Task FullyIsolated_AllWorktreeIdsAreUnique() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(3, WorktreeStrategy.FullyIsolated); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1"); + + // Get all session worktree IDs + var worktreeIds = svc.Organization.Sessions + .Where(s => s.GroupId == group!.Id) + .Select(s => s.WorktreeId) + .ToList(); + + // All should be non-null and unique + Assert.All(worktreeIds, id => Assert.NotNull(id)); + Assert.Equal(worktreeIds.Count, worktreeIds.Distinct().Count()); + } + + [Fact] + public async Task FullyIsolated_OrchestratorHasDifferentWorktreeThanWorkers() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(2, WorktreeStrategy.FullyIsolated); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1"); + + var orchMeta = svc.Organization.Sessions + .First(s => s.GroupId == group!.Id && s.Role == MultiAgentRole.Orchestrator); + var workerMetas = svc.Organization.Sessions + .Where(s => s.GroupId == group!.Id && s.Role != MultiAgentRole.Orchestrator) + .ToList(); + + Assert.NotNull(orchMeta.WorktreeId); + Assert.All(workerMetas, w => + { + Assert.NotNull(w.WorktreeId); + Assert.NotEqual(orchMeta.WorktreeId, w.WorktreeId); + }); + } + + [Fact] + public async Task FullyIsolated_SkipsFetchOnWorkerWorktrees() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(3, WorktreeStrategy.FullyIsolated); + + await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1"); + + // One explicit FetchAsync call upfront + Assert.Equal(1, rm.FetchCallCount); + // All CreateWorktreeAsync calls should have skipFetch=true + Assert.All(rm.CreateCalls, c => Assert.True(c.SkipFetch)); + } + + #endregion + + #region OrchestratorIsolated Strategy + + [Fact] + public async Task OrchestratorIsolated_Creates2Worktrees() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(3, WorktreeStrategy.OrchestratorIsolated); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1"); + + Assert.NotNull(group); + Assert.Equal(WorktreeStrategy.OrchestratorIsolated, group!.WorktreeStrategy); + + // 1 orchestrator + 1 shared worker = 2 worktrees + Assert.Equal(2, rm.CreateCalls.Count); + } + + [Fact] + public async Task OrchestratorIsolated_WorkersShareSameWorktree() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(3, WorktreeStrategy.OrchestratorIsolated); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1"); + + var workerMetas = svc.Organization.Sessions + .Where(s => s.GroupId == group!.Id && s.Role != MultiAgentRole.Orchestrator) + .ToList(); + + Assert.Equal(3, workerMetas.Count); + // All workers should share the same worktree ID + var workerWtIds = workerMetas.Select(w => w.WorktreeId).Distinct().ToList(); + Assert.Single(workerWtIds); + Assert.NotNull(workerWtIds[0]); + } + + [Fact] + public async Task OrchestratorIsolated_OrchestratorHasDifferentWorktreeThanWorkers() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(2, WorktreeStrategy.OrchestratorIsolated); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1"); + + var orchMeta = svc.Organization.Sessions + .First(s => s.GroupId == group!.Id && s.Role == MultiAgentRole.Orchestrator); + var workerMeta = svc.Organization.Sessions + .First(s => s.GroupId == group!.Id && s.Role != MultiAgentRole.Orchestrator); + + Assert.NotNull(orchMeta.WorktreeId); + Assert.NotNull(workerMeta.WorktreeId); + Assert.NotEqual(orchMeta.WorktreeId, workerMeta.WorktreeId); + } + + #endregion + + #region Shared Strategy + + [Fact] + public async Task Shared_CreatesNoWorktrees() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(3, WorktreeStrategy.Shared); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1"); + + Assert.NotNull(group); + Assert.Equal(WorktreeStrategy.Shared, group!.WorktreeStrategy); + + // No worktrees created + Assert.Empty(rm.CreateCalls); + Assert.Equal(0, rm.FetchCallCount); + } + + [Fact] + public async Task Shared_AllSessionsGetNullWorktreeId() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(2, WorktreeStrategy.Shared); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1"); + + var allMetas = svc.Organization.Sessions + .Where(s => s.GroupId == group!.Id) + .ToList(); + + Assert.Equal(3, allMetas.Count); // 1 orch + 2 workers + Assert.All(allMetas, m => Assert.Null(m.WorktreeId)); + } + + #endregion + + #region StrategyOverride + + [Fact] + public async Task StrategyOverride_OverridesPresetDefault() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + // Preset defaults to FullyIsolated + var preset = MakePreset(2, WorktreeStrategy.FullyIsolated); + + // Override to Shared + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1", + strategyOverride: WorktreeStrategy.Shared); + + Assert.Equal(WorktreeStrategy.Shared, group!.WorktreeStrategy); + Assert.Empty(rm.CreateCalls); + } + + [Fact] + public async Task StrategyOverride_NullUsesPresetDefault() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(2, WorktreeStrategy.FullyIsolated); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1", + strategyOverride: null); + + Assert.Equal(WorktreeStrategy.FullyIsolated, group!.WorktreeStrategy); + Assert.Equal(3, rm.CreateCalls.Count); // 1 orch + 2 workers + } + + [Fact] + public async Task NoRepoId_SkipsWorktreeCreation() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(2, WorktreeStrategy.FullyIsolated); + + // No repoId β€” can't create worktrees + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: null); + + Assert.NotNull(group); + Assert.Empty(rm.CreateCalls); + } + + #endregion + + #region Session Creation Correctness + + [Fact] + public async Task FullyIsolated_CorrectNumberOfSessions() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(5, WorktreeStrategy.FullyIsolated); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1"); + + var members = svc.Organization.Sessions + .Where(s => s.GroupId == group!.Id) + .ToList(); + + Assert.Equal(6, members.Count); // 1 orchestrator + 5 workers + Assert.Single(members.Where(m => m.Role == MultiAgentRole.Orchestrator)); + } + + [Fact] + public async Task FullyIsolated_OrchestratorIsPinned() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(2, WorktreeStrategy.FullyIsolated); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1"); + + var orchMeta = svc.Organization.Sessions + .First(s => s.GroupId == group!.Id && s.Role == MultiAgentRole.Orchestrator); + Assert.True(orchMeta.IsPinned); + } + + [Fact] + public async Task FullyIsolated_GroupWorktreeIdIsOrchestratorWorktree() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(2, WorktreeStrategy.FullyIsolated); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1"); + + var orchMeta = svc.Organization.Sessions + .First(s => s.GroupId == group!.Id && s.Role == MultiAgentRole.Orchestrator); + + // Group-level WorktreeId should match orchestrator's worktree + Assert.Equal(orchMeta.WorktreeId, group!.WorktreeId); + } + + #endregion + + #region Error Resilience + + [Fact] + public async Task WorktreeCreationFailure_StillCreatesSessions() + { + var rm = new FailingRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(3, WorktreeStrategy.FullyIsolated); + + // Should not throw + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1"); + + Assert.NotNull(group); + + // Sessions should still be created even though worktrees failed + var members = svc.Organization.Sessions + .Where(s => s.GroupId == group!.Id) + .ToList(); + + Assert.Equal(4, members.Count); // 1 orch + 3 workers + } + + /// + /// A RepoManager that always throws on worktree creation. + /// + private class FailingRepoManager : RepoManager + { + public FailingRepoManager(List repos) + { + var stateField = typeof(RepoManager).GetField("_state", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + var loadedField = typeof(RepoManager).GetField("_loaded", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + stateField.SetValue(this, new RepositoryState { Repositories = repos, Worktrees = new() }); + loadedField.SetValue(this, true); + } + + public override Task CreateWorktreeAsync(string repoId, string branchName, + string? baseBranch = null, bool skipFetch = false, CancellationToken ct = default) + { + throw new InvalidOperationException("Simulated git failure"); + } + + public override Task FetchAsync(string repoId, CancellationToken ct = default) + { + throw new InvalidOperationException("Simulated git fetch failure"); + } + } + + #endregion + + #region Branch Name Sanitization + + [Fact] + public async Task BranchNames_SpacesReplacedWithDashes() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(2, WorktreeStrategy.FullyIsolated); + + // "PR Review Squad" has spaces β€” branch names must not + await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1", + nameOverride: "PR Review Squad"); + + // All branch names should have no spaces + Assert.All(rm.CreateCalls, c => + { + Assert.DoesNotContain(" ", c.BranchName); + Assert.StartsWith("PR-Review-Squad-", c.BranchName); + }); + } + + [Fact] + public async Task BranchNames_SpecialCharsRemoved() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(1, WorktreeStrategy.FullyIsolated); + + await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1", + nameOverride: "My Team! @#$%"); + + Assert.All(rm.CreateCalls, c => + { + Assert.DoesNotContain(" ", c.BranchName); + Assert.DoesNotContain("!", c.BranchName); + Assert.DoesNotContain("@", c.BranchName); + Assert.DoesNotContain("#", c.BranchName); + }); + } + + #endregion + + #region CreatedWorktreeIds Tracking + + [Fact] + public async Task FullyIsolated_CreatedWorktreeIds_TracksAllWorktrees() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(3, WorktreeStrategy.FullyIsolated); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1"); + + // 1 orchestrator + 3 workers = 4 worktrees + Assert.Equal(4, group!.CreatedWorktreeIds.Count); + Assert.Equal(4, group.CreatedWorktreeIds.Distinct().Count()); + } + + [Fact] + public async Task OrchestratorIsolated_CreatedWorktreeIds_TracksAllWorktrees() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(3, WorktreeStrategy.OrchestratorIsolated); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1"); + + // 1 orchestrator + 1 shared worker = 2 worktrees + Assert.Equal(2, group!.CreatedWorktreeIds.Count); + Assert.Equal(2, group.CreatedWorktreeIds.Distinct().Count()); + } + + [Fact] + public async Task Shared_CreatedWorktreeIds_Empty() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(3, WorktreeStrategy.Shared); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1"); + + Assert.Empty(group!.CreatedWorktreeIds); + } + + [Fact] + public async Task FullyIsolated_CreatedWorktreeIds_MatchesSessionWorktreeIds() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(2, WorktreeStrategy.FullyIsolated); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1"); + + var sessionWtIds = svc.Organization.Sessions + .Where(s => s.GroupId == group!.Id && s.WorktreeId != null) + .Select(s => s.WorktreeId!) + .ToHashSet(); + + // All session worktree IDs should be in CreatedWorktreeIds + Assert.All(sessionWtIds, id => Assert.Contains(id, group!.CreatedWorktreeIds)); + } + + #endregion + + #region Review Finding: Shared strategy auto-creates worktree when no workingDirectory + + [Fact] + public async Task Shared_WithRepoButNoWorkDir_CreatesSharedWorktree() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(2, WorktreeStrategy.Shared); + + // workingDirectory: null and worktreeId: null β€” should auto-create a shared worktree + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: null, + repoId: "repo-1"); + + Assert.NotNull(group); + // Exactly 1 shared worktree created (not N per session) + Assert.Single(rm.CreateCalls); + Assert.Contains("shared", rm.CreateCalls[0].BranchName); + // Group should track the created worktree + Assert.Single(group!.CreatedWorktreeIds); + Assert.NotNull(group.WorktreeId); + + // All sessions (orch + workers) should share the same working directory + var organized = svc.GetOrganizedSessions(); + var groupSessions = organized.FirstOrDefault(g => g.Group.Id == group!.Id).Sessions; + Assert.NotNull(groupSessions); + Assert.Equal(3, groupSessions!.Count); // 1 orch + 2 workers + // All sessions should have a non-null working directory (the shared worktree path) + Assert.All(groupSessions, s => Assert.NotNull(s.WorkingDirectory)); + // All should share the same directory + var dirs = groupSessions.Select(s => s.WorkingDirectory).Distinct().ToList(); + Assert.Single(dirs); + } + + [Fact] + public async Task Shared_WithExistingWorkDir_CreatesNoWorktree() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(2, WorktreeStrategy.Shared); + + // workingDirectory provided β€” no auto-create needed + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/existing/dir", + repoId: "repo-1"); + + Assert.NotNull(group); + Assert.Empty(rm.CreateCalls); + } + + #endregion + + #region Review Finding: WorktreeId set on AgentSessionInfo (not just SessionMeta) + + [Fact] + public async Task FullyIsolated_WorktreeIdSetOnAgentSessionInfo() + { + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(2, WorktreeStrategy.FullyIsolated); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/fallback", + repoId: "repo-1"); + + // Check that AgentSessionInfo.WorktreeId is set (not just SessionMeta) + var groupSessionNames = svc.Organization.Sessions + .Where(s => s.GroupId == group!.Id) + .Select(s => s.SessionName) + .ToList(); + + Assert.Equal(3, groupSessionNames.Count); // 1 orch + 2 workers + + var organized = svc.GetOrganizedSessions(); + var groupEntry = organized.FirstOrDefault(g => g.Group.Id == group!.Id); + Assert.NotNull(groupEntry.Group); + Assert.All(groupEntry.Sessions, s => Assert.NotNull(s.WorktreeId)); + } + + #endregion +} diff --git a/PolyPilot/Components/Layout/CreateSessionForm.razor b/PolyPilot/Components/Layout/CreateSessionForm.razor index 3842092814..b7c65dd61b 100644 --- a/PolyPilot/Components/Layout/CreateSessionForm.razor +++ b/PolyPilot/Components/Layout/CreateSessionForm.razor @@ -11,117 +11,94 @@ else {
- - + @* Mode chips β€” shown only when repos exist *@ + @if (RepoManager.Repositories.Count > 0) + { +
+ + + +
+ } - @if (showOptions) + @* Repo mode: repo selector + worktree strategy *@ + @if (mode == FormMode.Repo) { -
- @if (RepoManager.Repositories.Count > 0) + @if (RepoManager.Repositories.Count > 1) + { + + } + else + { +
πŸ“¦ @RepoManager.Repositories[0].Name
+ } + +
+ + + + +
+ + @if (wtMode == WtMode.Branch) + { + + } + else if (wtMode == WtMode.PR) + { + + } + else if (wtMode == WtMode.Existing) + { + var repoWts = RepoManager.Worktrees.Where(w => w.RepoId == selectedRepoId).ToList(); + @if (repoWts.Count > 0) { -
- - @if (selectedWorktreeBranch != null) - { -
- β‘‚ @selectedWorktreeBranch - -
- } - else + - } - else - { - - } - -
- @if (!string.IsNullOrEmpty(worktreeError)) - { -
⚠ @worktreeError
- } - } - else - { - - } - } -
- } -
+ } - @if (filterRepoId == null) + else { -
- -
- - -
-
+
No existing worktrees β€” try ⚑ Auto or β‘‚ Branch
} + } + + @if (!string.IsNullOrEmpty(worktreeError)) + { +
⚠ @worktreeError
+ } + } + else if (mode == FormMode.Directory) + { +
+ +
} + + + @@ -140,214 +117,197 @@ else [Parameter] public string SelectedModel { get; set; } = "claude-opus-4.6"; [Parameter] public EventCallback SelectedModelChanged { get; set; } - [Parameter] public EventCallback<(string Name, string Model, string Directory, string? WorktreeId, string? InitialPrompt)> OnCreate { get; set; } + [Parameter] public EventCallback<(string Name, string Model, string Directory, string? WorktreeId, string? InitialPrompt, string? RepoId, string? BranchName, int? PrNumber)> OnCreate { get; set; } [Parameter] public EventCallback OnBrowseDirectory { get; set; } [Parameter] public string SessionName { get; set; } = ""; [Parameter] public EventCallback SessionNameChanged { get; set; } - private bool isExpanded = false; - private bool showOptions = false; - private bool showWorktreePicker = false; - private string? filterRepoId; // Filter worktree picker to a specific repo + private enum FormMode { Repo, Directory, Empty } + private enum WtMode { Auto, Branch, PR, Existing } + + private bool isExpanded; + private FormMode mode; + private WtMode wtMode = WtMode.Auto; + private string? selectedRepoId; + private string? selectedWorktreeId; + private string branchInput = ""; + private string prInput = ""; private string initialPrompt = ""; private string sessionDirectory = ""; - private string? selectedWorktreePath; - private string? selectedWorktreeId; - private string? selectedWorktreeBranch; - - private string? newWorktreeRepoId; - private string newWorktreeBranch = ""; - private string newWorktreePr = ""; - private string newWorktreeMode = "branch"; private string? worktreeError; private bool isCreatingWorktree; + private bool nameManuallyEdited; private void Expand() { isExpanded = true; - filterRepoId = null; // Clear any repo filter when manually expanding + nameManuallyEdited = false; + mode = RepoManager.Repositories.Count > 0 ? FormMode.Repo : FormMode.Empty; + if (RepoManager.Repositories.Count > 0) + selectedRepoId = RepoManager.Repositories[0].Id; + AutoSelectFirstWorktree(); } + public void ExpandForRepo(string repoId) { isExpanded = true; - showOptions = true; - worktreeError = null; - filterRepoId = repoId; // Filter to this repo only - showWorktreePicker = true; - newWorktreeRepoId = null; // Clear any open form so the button shows - - // If worktrees exist for this repo, auto-select the first one but keep picker open - var existingWorktrees = RepoManager.Worktrees.Where(w => w.RepoId == repoId).ToList(); - if (existingWorktrees.Count > 0) - { - var wt = existingWorktrees[0]; - sessionDirectory = wt.Path; - selectedWorktreePath = wt.Path; - selectedWorktreeId = wt.Id; - selectedWorktreeBranch = wt.Branch; - // Don't close the picker - we want to show the "+ New worktree..." button - if (string.IsNullOrWhiteSpace(SessionName)) - { - SessionName = wt.Branch; - _ = SessionNameChanged.InvokeAsync(SessionName); - } - } - + nameManuallyEdited = false; + mode = FormMode.Repo; + selectedRepoId = repoId; + wtMode = WtMode.Auto; + AutoSelectFirstWorktree(); StateHasChanged(); } + private void Collapse() { isExpanded = false; - showOptions = false; - showWorktreePicker = false; - newWorktreeRepoId = null; - filterRepoId = null; // Clear repo filter + worktreeError = null; + branchInput = ""; + prInput = ""; + sessionDirectory = ""; + initialPrompt = ""; + selectedWorktreeId = null; + nameManuallyEdited = false; } - private void SetWorktreeMode(string mode) => newWorktreeMode = mode; - private void SetModeBranch() => newWorktreeMode = "branch"; - private void SetModePr() => newWorktreeMode = "pr"; + private void SetMode(FormMode newMode) + { + mode = newMode; + worktreeError = null; + if (!nameManuallyEdited) { SessionName = ""; _ = SessionNameChanged.InvokeAsync(""); } + } - private void ToggleWorktreePicker() + private void SetWtMode(WtMode newWtMode) { - showWorktreePicker = !showWorktreePicker; - // When manually toggling picker, clear the filter to show all repos - if (showWorktreePicker) filterRepoId = null; + wtMode = newWtMode; + worktreeError = null; + branchInput = ""; + prInput = ""; + if (newWtMode == WtMode.Existing) + AutoSelectFirstWorktree(); + if (!nameManuallyEdited) UpdateAutoName(); } - private async Task OnNameInput(ChangeEventArgs e) + private void OnRepoChanged(ChangeEventArgs e) { - SessionName = e.Value?.ToString() ?? ""; - await SessionNameChanged.InvokeAsync(SessionName); + selectedRepoId = e.Value?.ToString(); + if (wtMode == WtMode.Existing) AutoSelectFirstWorktree(); + if (!nameManuallyEdited) UpdateAutoName(); } - private async Task OnModelSelected(string model) + private void OnExistingWtChanged(ChangeEventArgs e) { - SelectedModel = model; - await SelectedModelChanged.InvokeAsync(model); + selectedWorktreeId = e.Value?.ToString(); + if (!nameManuallyEdited) UpdateAutoName(); + } + + private void AutoSelectFirstWorktree() + { + if (selectedRepoId == null) return; + var first = RepoManager.Worktrees.FirstOrDefault(w => w.RepoId == selectedRepoId); + selectedWorktreeId = first?.Id; } - private void SelectWorktree(WorktreeInfo wt) + private void UpdateAutoName() { - sessionDirectory = wt.Path; - selectedWorktreePath = wt.Path; - selectedWorktreeId = wt.Id; - selectedWorktreeBranch = wt.Branch; - showWorktreePicker = false; - newWorktreeRepoId = null; - - if (string.IsNullOrWhiteSpace(SessionName)) + if (nameManuallyEdited) return; + string? auto = null; + if (mode == FormMode.Repo) { - SessionName = wt.Branch; - _ = SessionNameChanged.InvokeAsync(SessionName); + if (wtMode == WtMode.Branch && !string.IsNullOrWhiteSpace(branchInput)) + auto = branchInput.Trim(); + else if (wtMode == WtMode.PR && !string.IsNullOrWhiteSpace(prInput)) + auto = $"PR #{prInput.Trim().TrimStart('#')}"; + else if (wtMode == WtMode.Existing && selectedWorktreeId != null) + { + var wt = RepoManager.Worktrees.FirstOrDefault(w => w.Id == selectedWorktreeId); + if (wt != null) auto = wt.Branch; + } + } + if (auto != null) + { + SessionName = auto; + _ = SessionNameChanged.InvokeAsync(auto); } } - private void ClearWorktree() + private async Task OnNameInput(ChangeEventArgs e) { - selectedWorktreePath = null; - selectedWorktreeId = null; - selectedWorktreeBranch = null; - sessionDirectory = ""; + SessionName = e.Value?.ToString() ?? ""; + nameManuallyEdited = !string.IsNullOrWhiteSpace(SessionName); + await SessionNameChanged.InvokeAsync(SessionName); } - private void StartNewWorktreeForm(string repoId) + private async Task OnModelSelected(string model) { - newWorktreeRepoId = repoId; - newWorktreeBranch = ""; - newWorktreePr = ""; - newWorktreeMode = "branch"; - worktreeError = null; + SelectedModel = model; + await SelectedModelChanged.InvokeAsync(model); } - private async Task HandleNewWorktreeKeyUp(KeyboardEventArgs e) + private async Task HandleInputKeyUp(KeyboardEventArgs e) { - if (e.Key == "Enter") await CreateAndSelectWorktree(); - else if (e.Key == "Escape") newWorktreeRepoId = null; + if (!nameManuallyEdited) UpdateAutoName(); + if (e.Key == "Enter") await TriggerCreate(); + else if (e.Key == "Escape") Collapse(); } - private async Task CreateAndSelectWorktree() + private async Task HandleKeyUp(KeyboardEventArgs e) { - if (isCreatingWorktree || newWorktreeRepoId == null) return; - if (newWorktreeMode == "pr") + if (e.Key == "Escape") Collapse(); + else if (e.Key == "Enter") await TriggerCreate(); + } + + private async Task TriggerCreate() + { + if (string.IsNullOrWhiteSpace(SessionName) || IsCreating || isCreatingWorktree) return; + + var prompt = string.IsNullOrWhiteSpace(initialPrompt) ? null : initialPrompt.Trim(); + string? repoId = null; + string? branchName = null; + int? prNumber = null; + string? wtId = null; + string? dir = mode == FormMode.Empty ? null : ""; + + if (mode == FormMode.Repo && selectedRepoId != null) { - if (!int.TryParse(newWorktreePr.Trim().TrimStart('#'), out var prNum) || prNum <= 0) - { worktreeError = "Enter a valid PR number"; return; } - isCreatingWorktree = true; worktreeError = null; - try + repoId = selectedRepoId; + if (wtMode == WtMode.Auto) { - if (CopilotService.IsRemoteMode) - { - var result = await CopilotService.CreateWorktreeViaBridgeAsync(newWorktreeRepoId, null, prNum); - var wt = new WorktreeInfo { Id = result.WorktreeId, RepoId = result.RepoId, Branch = result.Branch, Path = result.Path, PrNumber = result.PrNumber }; - RepoManager.AddRemoteWorktree(wt); - SelectWorktree(wt); - } - else - { - var wt = await RepoManager.CreateWorktreeFromPrAsync(newWorktreeRepoId, prNum); - SelectWorktree(wt); - } - if (string.IsNullOrWhiteSpace(SessionName)) - { SessionName = $"PR #{prNum}"; await SessionNameChanged.InvokeAsync(SessionName); } + // Auto-generate branch name from session name + var sanitized = System.Text.RegularExpressions.Regex.Replace(SessionName.Trim(), @"[^a-zA-Z0-9_-]", "-").Trim('-'); + if (string.IsNullOrEmpty(sanitized)) sanitized = "session"; + branchName = $"{sanitized}-{Guid.NewGuid().ToString()[..4]}"; } - catch (Exception ex) { worktreeError = ex.Message; } - finally { isCreatingWorktree = false; } - } - else - { - if (string.IsNullOrWhiteSpace(newWorktreeBranch)) return; - isCreatingWorktree = true; worktreeError = null; - try + else if (wtMode == WtMode.Branch) { - WorktreeInfo wt; - if (CopilotService.IsRemoteMode) - { - var result = await CopilotService.CreateWorktreeViaBridgeAsync(newWorktreeRepoId, newWorktreeBranch.Trim(), null); - wt = new WorktreeInfo { Id = result.WorktreeId, RepoId = result.RepoId, Branch = result.Branch, Path = result.Path, PrNumber = result.PrNumber }; - RepoManager.AddRemoteWorktree(wt); - } - else - { - wt = await RepoManager.CreateWorktreeAsync(newWorktreeRepoId, newWorktreeBranch.Trim()); - } - SelectWorktree(wt); - if (string.IsNullOrWhiteSpace(SessionName)) - { SessionName = wt.Branch; await SessionNameChanged.InvokeAsync(SessionName); } + if (string.IsNullOrWhiteSpace(branchInput)) { worktreeError = "Enter a branch name"; return; } + branchName = branchInput.Trim(); + } + else if (wtMode == WtMode.PR) + { + if (!int.TryParse(prInput.Trim().TrimStart('#'), out var pr) || pr <= 0) + { worktreeError = "Enter a valid PR number"; return; } + prNumber = pr; + } + else if (wtMode == WtMode.Existing) + { + if (string.IsNullOrEmpty(selectedWorktreeId)) { worktreeError = "Select a worktree"; return; } + wtId = selectedWorktreeId; } - catch (Exception ex) { worktreeError = ex.Message; } - finally { isCreatingWorktree = false; } } - } - - private async Task RemoveWorktree(string worktreeId) - { - try + else if (mode == FormMode.Directory) { - if (selectedWorktreeId == worktreeId) ClearWorktree(); - if (CopilotService.IsRemoteMode) - await CopilotService.RemoveWorktreeViaBridgeAsync(worktreeId); - else - await RepoManager.RemoveWorktreeAsync(worktreeId); + dir = sessionDirectory; } - catch (Exception ex) { worktreeError = ex.Message; } - } - private async Task TriggerCreate() - { - if (!string.IsNullOrWhiteSpace(SessionName)) - { - var prompt = string.IsNullOrWhiteSpace(initialPrompt) ? null : initialPrompt.Trim(); - await OnCreate.InvokeAsync((SessionName, SelectedModel, sessionDirectory, selectedWorktreeId, prompt)); - selectedWorktreePath = null; selectedWorktreeId = null; selectedWorktreeBranch = null; - sessionDirectory = ""; initialPrompt = ""; - isExpanded = false; showOptions = false; - } - } + worktreeError = null; + await OnCreate.InvokeAsync((SessionName, SelectedModel, dir, wtId, prompt, repoId, branchName, prNumber)); - private async Task HandleKeyUp(KeyboardEventArgs e) - { - if (e.Key == "Escape") Collapse(); + // Reset form + branchInput = ""; prInput = ""; sessionDirectory = ""; initialPrompt = ""; + selectedWorktreeId = null; nameManuallyEdited = false; + isExpanded = false; } private async Task BrowseDirectory() @@ -357,7 +317,7 @@ else { var dir = await FolderPickerService.PickFolderAsync(); if (!string.IsNullOrEmpty(dir)) - { sessionDirectory = dir; selectedWorktreePath = null; selectedWorktreeId = null; selectedWorktreeBranch = null; StateHasChanged(); } + { sessionDirectory = dir; StateHasChanged(); } } catch (Exception ex) { Console.WriteLine($"Folder picker error: {ex.Message}"); } #else @@ -367,7 +327,7 @@ else public void SetDirectory(string path) { - sessionDirectory = path; selectedWorktreePath = null; selectedWorktreeId = null; selectedWorktreeBranch = null; + sessionDirectory = path; StateHasChanged(); } } diff --git a/PolyPilot/Components/Layout/CreateSessionForm.razor.css b/PolyPilot/Components/Layout/CreateSessionForm.razor.css index 43f5c06068..46b7229410 100644 --- a/PolyPilot/Components/Layout/CreateSessionForm.razor.css +++ b/PolyPilot/Components/Layout/CreateSessionForm.razor.css @@ -21,152 +21,192 @@ .new-session-form { display: flex; flex-direction: column; - gap: 0.6rem; + gap: 0.5rem; padding: 0.75rem; border: none; border-radius: 10px; background: var(--surface-secondary); } -.ns-name { - width: 100%; - padding: 0.5rem 0.65rem; - border: 1px solid transparent; +/* === Mode Chips === */ +.ns-mode-chips { + display: flex; + gap: 0.25rem; + padding-bottom: 0.15rem; +} + +.ns-chip { + flex: 1; + padding: 0.3rem 0.4rem; + border: 1px solid var(--control-border); border-radius: 6px; - background: var(--control-bg); - color: var(--text-primary); - font-size: var(--type-body); - box-sizing: border-box; - transition: border-color 0.15s; + background: none; + color: var(--text-dim); + cursor: pointer; + font-size: var(--type-caption1); + font-weight: 500; + text-align: center; + transition: all 0.15s; + white-space: nowrap; } -.ns-name:focus { - outline: none; - border-color: var(--accent-primary); +.ns-chip:hover { + color: var(--text-primary); + background: var(--hover-bg); } -.ns-name::placeholder { - color: var(--text-dim); +.ns-chip.active { + background: var(--accent-primary); + color: #fff; + border-color: var(--accent-primary); } -.ns-prompt { +/* === Repo selector === */ +.ns-repo-select { width: 100%; - padding: 0.45rem 0.65rem; - border: 1px solid transparent; + padding: 0.35rem 0.5rem; + border: 1px solid var(--control-border); border-radius: 6px; background: var(--control-bg); color: var(--text-primary); font-size: var(--type-footnote); - font-family: inherit; - resize: vertical; - min-height: 2.2rem; - max-height: 6rem; - box-sizing: border-box; - transition: border-color 0.15s; + cursor: pointer; } -.ns-prompt:focus { +.ns-repo-select:focus { outline: none; border-color: var(--accent-primary); } -.ns-prompt::placeholder { - color: var(--text-dim); -} - -/* === Options Panel === */ -.ns-options { - display: flex; - flex-direction: column; - gap: 0.5rem; - padding-top: 0.25rem; - border-top: 1px solid var(--control-border); +.ns-repo-label { + font-size: var(--type-footnote); + color: var(--text-secondary); + padding: 0.15rem 0; } -.ns-option-group { +/* === Worktree Segment Control === */ +.ns-wt-segment { display: flex; - flex-direction: column; - gap: 0.25rem; + border: 1px solid var(--control-border); + border-radius: 6px; + overflow: hidden; } -.ns-label { - font-size: var(--type-caption2); +.ns-seg { + flex: 1; + padding: 0.3rem 0.2rem; + border: none; + border-right: 1px solid var(--control-border); + background: none; color: var(--text-dim); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.03em; + cursor: pointer; + font-size: var(--type-caption2); + font-weight: 500; + text-align: center; + transition: all 0.15s; + white-space: nowrap; } -.ns-option-btn { - background: none; - border: none; - color: var(--text-secondary); - cursor: pointer; - font-size: var(--type-footnote); - text-align: left; - padding: 0.2rem 0; +.ns-seg:last-child { + border-right: none; } -.ns-option-btn:hover { +.ns-seg:hover { color: var(--text-primary); + background: var(--hover-bg); } -.ns-wt-badge { - display: flex; - align-items: center; - gap: 0.3rem; +.ns-seg.active { + background: var(--accent-primary); + color: #fff; +} + +/* === Shared input style === */ +.ns-input { + width: 100%; + padding: 0.4rem 0.55rem; + border: 1px solid var(--control-border); + border-radius: 6px; + background: var(--control-bg); + color: var(--text-primary); font-size: var(--type-footnote); - color: var(--accent-primary); + box-sizing: border-box; + transition: border-color 0.15s; } -.ns-badge-clear { - background: none; - border: none; +.ns-input:focus { + outline: none; + border-color: var(--accent-primary); +} + +.ns-input::placeholder { color: var(--text-dim); - cursor: pointer; +} + +.ns-hint { font-size: var(--type-caption2); - padding: 0 0.2rem; + color: var(--text-dim); + padding: 0.2rem 0; + font-style: italic; } -.ns-badge-clear:hover { +.ns-name { + width: 100%; + padding: 0.5rem 0.65rem; + border: 1px solid transparent; + border-radius: 6px; + background: var(--control-bg); color: var(--text-primary); + font-size: var(--type-body); + box-sizing: border-box; + transition: border-color 0.15s; } -.ns-wt-picker { - background: var(--control-bg); - border: 1px solid var(--control-border); - border-radius: 6px; - overflow: hidden; - max-height: 200px; - overflow-y: auto; +.ns-name:focus { + outline: none; + border-color: var(--accent-primary); } -.ns-dir-row { - display: flex; - gap: 0.3rem; +.ns-name::placeholder { + color: var(--text-dim); } -.ns-dir-input { - flex: 1; - min-width: 0; - padding: 0.3rem 0.5rem; - border: 1px solid var(--control-border); - border-radius: 4px; +.ns-prompt { + width: 100%; + padding: 0.45rem 0.65rem; + border: 1px solid transparent; + border-radius: 6px; background: var(--control-bg); color: var(--text-primary); font-size: var(--type-footnote); + font-family: inherit; + resize: vertical; + min-height: 2.2rem; + max-height: 6rem; + box-sizing: border-box; + transition: border-color 0.15s; } -.ns-dir-input:focus { +.ns-prompt:focus { outline: none; border-color: var(--accent-primary); } +.ns-prompt::placeholder { + color: var(--text-dim); +} + +/* === Directory row === */ +.ns-dir-row { + display: flex; + gap: 0.3rem; +} + .ns-dir-browse { - padding: 0.3rem 0.5rem; + padding: 0.35rem 0.5rem; background: var(--control-bg); border: 1px solid var(--control-border); - border-radius: 4px; + border-radius: 6px; color: var(--text-primary); cursor: pointer; font-size: var(--type-footnote); @@ -190,26 +230,10 @@ min-width: 0; } -.ns-options-toggle { - background: none; - border: none; - color: var(--text-dim); - cursor: pointer; - font-size: var(--type-caption1); - padding: 0.25rem 0.35rem; - white-space: nowrap; - border-radius: 4px; - transition: all 0.15s; -} - -.ns-options-toggle:hover { - color: var(--text-primary); - background: var(--hover-bg); -} - .ns-footer-actions { display: flex; gap: 0.35rem; + margin-left: auto; } .ns-cancel { @@ -256,155 +280,6 @@ padding: 0.1rem 0; } -/* === Worktree picker items (reused from before) === */ -.wt-repo-name { - font-size: var(--type-caption2); - color: var(--text-dim); - padding: 0.3rem 0.5rem 0.1rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.03em; -} - -.wt-option-row { - display: flex; - align-items: center; -} - -.wt-option-row .wt-option { - flex: 1; - min-width: 0; -} - -.wt-option { - display: flex; - align-items: center; - gap: 0.5rem; - width: 100%; - padding: 0.3rem 0.5rem; - border: none; - background: none; - color: var(--text-primary); - cursor: pointer; - font-size: var(--type-footnote); - text-align: left; -} - -.wt-option:hover { - background: var(--hover-bg); -} - -.wt-option.selected { - background: var(--hover-bg); -} - -.wt-option.wt-new { - color: var(--accent-primary); - font-size: var(--type-footnote); -} - -.wt-delete-btn { - background: none; - border: none; - color: var(--text-dim); - cursor: pointer; - font-size: var(--type-footnote); - padding: 0.2rem 0.4rem; - opacity: 0; - transition: opacity 0.15s; -} - -.wt-option-row:hover .wt-delete-btn { - opacity: 1; -} - -.wt-delete-btn:hover { - color: #ff6b6b; -} - -.wt-pr-badge { - font-size: var(--type-caption2); - color: var(--accent-primary); - margin-left: auto; -} - -.wt-mode-toggle { - display: flex; - padding: 0.2rem 0.5rem; - gap: 0; -} - -.wt-mode { - flex: 1; - padding: 0.2rem 0; - border: 1px solid var(--control-border); - background: none; - color: var(--text-dim); - cursor: pointer; - font-size: var(--type-caption2); - font-weight: 600; - text-align: center; -} - -.wt-mode:first-child { - border-radius: 4px 0 0 4px; -} - -.wt-mode:last-child { - border-radius: 0 4px 4px 0; - border-left: none; -} - -.wt-mode.active { - background: var(--control-bg); - color: var(--text-primary); - border-color: var(--accent-primary); -} - -.wt-new-form { - display: flex; - gap: 0.3rem; - padding: 0.25rem 0.5rem; -} - -.wt-branch-input { - flex: 1; - min-width: 0; - padding: 0.25rem 0.4rem; - border: 1px solid var(--control-border); - border-radius: 4px; - background: var(--surface-secondary); - color: var(--text-primary); - font-size: var(--type-footnote); -} - -.wt-branch-input:focus { - outline: none; - border-color: var(--accent-primary); -} - -.wt-create-btn { - padding: 0.25rem 0.5rem; - border: none; - border-radius: 4px; - background: var(--accent-primary); - color: var(--text-primary); - cursor: pointer; - font-size: var(--type-footnote); - font-weight: bold; -} - -.wt-create-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.wt-error { - color: #ff6b6b; - font-size: var(--type-caption2); - padding: 0.15rem 0.5rem; -} - /* === Mobile === */ @media (max-width: 640px) { .new-session-btn { @@ -420,4 +295,9 @@ .ns-name { font-size: var(--type-callout); } + + .ns-chip { + font-size: var(--type-caption2); + padding: 0.25rem 0.3rem; + } } diff --git a/PolyPilot/Components/Layout/SessionListItem.razor b/PolyPilot/Components/Layout/SessionListItem.razor index f032a86d42..3214bcbfc3 100644 --- a/PolyPilot/Components/Layout/SessionListItem.razor +++ b/PolyPilot/Components/Layout/SessionListItem.razor @@ -3,6 +3,8 @@ @using System.Diagnostics @inject CopilotService CopilotService @inject RepoManager RepoManager +@inject IJSRuntime JS +@implements IAsyncDisposable @{ var isPinned = Meta?.IsPinned ?? false; @@ -197,20 +199,41 @@ } - - @if (!string.IsNullOrEmpty(Meta?.WorktreeId)) - { - - } } +@if (showCloseConfirm) +{ +
+
+
Close "@Session.Name"?
+ @if (!string.IsNullOrEmpty(Meta?.WorktreeId)) + { + var wt = RepoManager.Worktrees.FirstOrDefault(w => w.Id == Meta.WorktreeId); +
+ + +
+ } +
+ + +
+
+
+} + @code { [Parameter] public AgentSessionInfo Session { get; set; } = null!; [Parameter] public SessionMeta? Meta { get; set; } @@ -224,7 +247,7 @@ [Parameter] public EventCallback OnSelect { get; set; } [Parameter] public EventCallback OnClose { get; set; } - [Parameter] public EventCallback OnCloseAndDeleteWorktree { get; set; } + [Parameter] public EventCallback<(bool DeleteWorktree, bool DeleteBranch)> OnCloseWithOptions { get; set; } [Parameter] public EventCallback OnPin { get; set; } [Parameter] public EventCallback OnMove { get; set; } [Parameter] public EventCallback OnStartRename { get; set; } @@ -234,6 +257,58 @@ [Parameter] public EventCallback OnReportBug { get; set; } [Parameter] public EventCallback OnFixWithCopilot { get; set; } + private bool showCloseConfirm; + private bool deleteWorktree; + private bool deleteBranch; + private ElementReference dialogOverlayRef; + private readonly string _dialogId = Guid.NewGuid().ToString("N")[..8]; + + private string DialogSelector => $"[data-dialog-id='{_dialogId}']"; + + private async Task ShowCloseConfirm() + { + showCloseConfirm = true; + // Default both to checked when worktree exists + deleteWorktree = !string.IsNullOrEmpty(Meta?.WorktreeId); + deleteBranch = !string.IsNullOrEmpty(Meta?.WorktreeId); + StateHasChanged(); + // Portal the overlay to document.body after render so it escapes + // overflow:hidden on sidebar/session-list parent containers. + await Task.Yield(); + try { await JS.InvokeVoidAsync("eval", $"var el=document.querySelector(\"{DialogSelector}\");if(el&&el.parentElement!==document.body)document.body.appendChild(el)"); } + catch { /* JS interop may fail during dispose */ } + } + + private async Task RemovePortaledOverlay() + { + try { await JS.InvokeVoidAsync("eval", $"var el=document.querySelector(\"{DialogSelector}\");if(el&&el.parentElement===document.body)el.remove()"); } + catch { } + } + + private async Task HideCloseConfirm() + { + await RemovePortaledOverlay(); + showCloseConfirm = false; + await OnCloseMenu.InvokeAsync(); + } + + private void OnDeleteWorktreeChanged() + { + // Can't delete a branch that's checked out in a kept worktree + if (!deleteWorktree) deleteBranch = false; + } + + private async Task ConfirmClose() + { + await RemovePortaledOverlay(); + showCloseConfirm = false; + await OnCloseMenu.InvokeAsync(); + if (!string.IsNullOrEmpty(Meta?.WorktreeId) && (deleteWorktree || deleteBranch)) + await OnCloseWithOptions.InvokeAsync((deleteWorktree, deleteBranch)); + else + await OnClose.InvokeAsync(); + } + private async Task HandleRenameKeyUp(KeyboardEventArgs e) { if (e.Key == "Enter") await OnCommitRename.InvokeAsync(); @@ -368,4 +443,10 @@ private static string FormatTokenCount(int count) => count >= 1000 ? $"{count / 1000}k" : count.ToString(); + + public async ValueTask DisposeAsync() + { + if (showCloseConfirm) + await RemovePortaledOverlay(); + } } diff --git a/PolyPilot/Components/Layout/SessionListItem.razor.css b/PolyPilot/Components/Layout/SessionListItem.razor.css index 6a76ddf420..d81412a481 100644 --- a/PolyPilot/Components/Layout/SessionListItem.razor.css +++ b/PolyPilot/Components/Layout/SessionListItem.razor.css @@ -319,3 +319,98 @@ padding: 0.15rem 0; opacity: 0.8; } + +/* === Close confirmation dialog (modal popup) === */ +.close-dialog-overlay { + position: fixed !important; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} + +.close-dialog { + background: #2a2a3e; + border: 2px solid rgba(124, 92, 252, 0.6); + border-radius: 12px; + padding: 1.5rem; + min-width: 300px; + max-width: 380px; + display: flex; + flex-direction: column; + gap: 0.75rem; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.8), 0 0 0 1px rgba(124, 92, 252, 0.4), 0 0 80px rgba(124, 92, 252, 0.15); + position: relative; + z-index: 10001; +} + +.close-dialog-title { + font-size: var(--type-body); + font-weight: 600; + color: #fff; +} + +.close-dialog-options { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.close-dialog-option { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: var(--type-footnote); + color: var(--text-secondary); + cursor: pointer; + padding: 0.25rem 0; +} + +.close-dialog-option input[type="checkbox"] { + accent-color: var(--accent-primary); + width: 15px; + height: 15px; + cursor: pointer; +} + +.close-dialog-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + padding-top: 0.25rem; +} + +.close-dialog-cancel { + padding: 0.35rem 0.75rem; + border: 1px solid var(--control-border); + border-radius: 6px; + background: none; + color: var(--text-secondary); + cursor: pointer; + font-size: var(--type-footnote); +} + +.close-dialog-cancel:hover { + color: var(--text-primary); + background: var(--hover-bg); +} + +.close-dialog-btn { + padding: 0.35rem 0.75rem; + border: none; + border-radius: 6px; + background: #ff6b6b; + color: #fff; + cursor: pointer; + font-size: var(--type-footnote); + font-weight: 600; +} + +.close-dialog-btn:hover { + filter: brightness(1.1); +} diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor b/PolyPilot/Components/Layout/SessionSidebar.razor index 5707392198..732de169c1 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor +++ b/PolyPilot/Components/Layout/SessionSidebar.razor @@ -204,49 +204,57 @@ else - @if (isAddingGroup && !isAddingMultiAgentGroup && pendingMultiAgentWorktree == null) + @if (isAddingGroup && !isAddingMultiAgentGroup && pendingMultiAgentRepo == null) { } - else if (isAddingMultiAgentGroup && pendingMultiAgentWorktree == null) + else if (isAddingMultiAgentGroup && pendingMultiAgentRepo == null) { - @* Step 1: Pick a worktree *@ + @* Step 1: Pick a repository *@
- Select worktree for team: + Select repository for team:
- @foreach (var wt in RepoManager.Worktrees) + @foreach (var repo in RepoManager.Repositories) { - var w = wt; - var repo = RepoManager.Repositories.FirstOrDefault(r => r.Id == w.RepoId); - } - @if (!RepoManager.Worktrees.Any()) + @if (!RepoManager.Repositories.Any()) { -
No worktrees available. Add a repository first.
+
No repositories available. Add a repository first.
}
} - else if (pendingMultiAgentWorktree != null) + else if (pendingMultiAgentRepo != null) { @* Step 2: Pick a preset or enter a custom name *@
- 🌿 @pendingMultiAgentWorktree.Branch + πŸ“¦ @pendingMultiAgentRepo.Name
+
+
🌿 Worktree Isolation
+ +
@{ - var allPresets = UserPresets.GetAll(CopilotService.BaseDir, pendingMultiAgentWorktree?.Path); + var repoWorktree = RepoManager.Worktrees.FirstOrDefault(w => w.RepoId == pendingMultiAgentRepo?.Id); + var allPresets = UserPresets.GetAll(CopilotService.BaseDir, repoWorktree?.Path); var repoPresets = allPresets.Where(p => p.IsRepoLevel).ToArray(); var builtInPresets = allPresets.Where(p => !p.IsRepoLevel && !p.IsUserDefined).ToArray(); var userPresets = allPresets.Where(p => p.IsUserDefined).ToArray(); @@ -257,7 +265,7 @@ else @foreach (var preset in repoPresets) { var p = preset; - @@ -285,7 +293,7 @@ else @foreach (var preset in userPresets) { var p = preset; - @@ -441,6 +449,12 @@ else + + @@ -457,9 +471,12 @@ else } } - + @if (!group.IsMultiAgent) + { + + } } else { @@ -490,6 +507,39 @@ else }
+ @if (quickBranchRepoId == group.RepoId && isRepoGroup) + { + var qbRepoId = group.RepoId!; +
+ β‘‚ + + @if (quickBranchIsCreating) + { + ⏳ + } + else + { + + + } + @if (!string.IsNullOrEmpty(quickBranchError)) + { +
⚠ @quickBranchError
+ } +
+ } + @if (quickCreateErrorRepoId == group.RepoId && !string.IsNullOrEmpty(quickCreateError)) + { +
+ ⚠ @quickCreateError + +
+ } + @if (group.IsMultiAgent && !group.IsCollapsed) { var maGroupId = group.Id; @@ -580,7 +630,7 @@ else UsageInfo="@(usageBySession.TryGetValue(session.Name, out var usg) ? usg : null)" OnSelect="() => SelectSession(sName)" OnClose="() => CloseSession(sName)" - OnCloseAndDeleteWorktree="() => CloseSessionAndDeleteWorktree(sName)" + OnCloseWithOptions="(opts) => CloseSessionWithOptions(sName, opts.DeleteWorktree, opts.DeleteBranch)" OnPin="(pinned) => { CopilotService.PinSession(sName, pinned); }" OnMove="(groupId) => CopilotService.MoveSession(sName, groupId)" OnStartRename="() => StartRename(sName)" @@ -685,6 +735,14 @@ else private string? confirmRepoName = null; private string? confirmRemoveRepoId = null; + // Quick-create inline branch input + private string? quickBranchRepoId = null; + private string quickBranchInput = ""; + private bool quickBranchIsCreating = false; + private string? quickBranchError = null; + private string? quickCreateError = null; + private string? quickCreateErrorRepoId = null; + private async Task ToggleFlyout() { await OnToggleFlyout.InvokeAsync(); @@ -883,43 +941,49 @@ else } } - private async Task HandleCreateSession((string Name, string Model, string Directory, string? WorktreeId, string? InitialPrompt) args) + private async Task HandleCreateSession((string Name, string Model, string Directory, string? WorktreeId, string? InitialPrompt, string? RepoId, string? BranchName, int? PrNumber) args) { if (isCreating) return; isCreating = true; createError = null; try { - var sessionInfo = await CopilotService.CreateSessionAsync(args.Name, args.Model, args.Directory); - CopilotService.SwitchSession(sessionInfo.Name); - newSessionName = ""; + AgentSessionInfo sessionInfo; - // If created from a worktree, place session in the repo group - if (!string.IsNullOrEmpty(args.WorktreeId)) + if (!string.IsNullOrEmpty(args.RepoId) || !string.IsNullOrEmpty(args.WorktreeId)) { - var wt = RepoManager.Worktrees.FirstOrDefault(w => w.Id == args.WorktreeId); - if (wt != null) + // Repo-based: atomic worktree+session creation. + // When only WorktreeId is set, derive repoId from the worktree. + var repoId = args.RepoId; + if (string.IsNullOrEmpty(repoId) && !string.IsNullOrEmpty(args.WorktreeId)) { - RepoManager.LinkSessionToWorktree(wt.Id, sessionInfo.Name); - var repo = RepoManager.Repositories.FirstOrDefault(r => r.Id == wt.RepoId); - if (repo != null) - { - var group = CopilotService.GetOrCreateRepoGroup(repo.Id, repo.Name); - CopilotService.MoveSession(sessionInfo.Name, group.Id); - var meta = CopilotService.GetSessionMeta(sessionInfo.Name); - if (meta != null) meta.WorktreeId = wt.Id; - } + var wt = RepoManager.Worktrees.FirstOrDefault(w => w.Id == args.WorktreeId); + repoId = wt?.RepoId ?? ""; + } + sessionInfo = await CopilotService.CreateSessionWithWorktreeAsync( + repoId: repoId!, + branchName: args.BranchName, + prNumber: args.PrNumber, + worktreeId: args.WorktreeId, + sessionName: args.Name, + model: args.Model, + initialPrompt: args.InitialPrompt); + } + else + { + sessionInfo = await CopilotService.CreateSessionAsync(args.Name, args.Model, args.Directory); + CopilotService.SwitchSession(sessionInfo.Name); + + // Send initial prompt after session is ready + if (!string.IsNullOrEmpty(args.InitialPrompt)) + { + _ = CopilotService.SendPromptAsync(sessionInfo.Name, args.InitialPrompt); } } + newSessionName = ""; CopilotService.SaveUiState(currentPage, selectedModel: selectedModel); await OnSessionSelected.InvokeAsync(); - - // Send initial prompt after session is ready - if (!string.IsNullOrEmpty(args.InitialPrompt)) - { - _ = CopilotService.SendPromptAsync(sessionInfo.Name, args.InitialPrompt); - } } catch (Exception ex) { @@ -932,6 +996,92 @@ else } } + private async Task QuickCreateSessionForRepo(string repoId) + { + if (isCreating) return; + isCreating = true; + createError = null; + quickCreateError = null; + quickCreateErrorRepoId = null; + try + { + var sessionInfo = await CopilotService.CreateSessionWithWorktreeAsync( + repoId: repoId, + model: selectedModel); + CopilotService.SaveUiState(currentPage, selectedModel: selectedModel); + await OnSessionSelected.InvokeAsync(); + } + catch (Exception ex) + { + createError = ex.Message; + quickCreateError = ex.Message; + quickCreateErrorRepoId = repoId; + Console.WriteLine($"Error quick-creating session: {ex}"); + } + finally + { + isCreating = false; + } + } + + private void StartQuickBranch(string repoId) + { + quickBranchRepoId = repoId; + quickBranchInput = ""; + quickBranchError = null; + } + + private async Task HandleQuickBranchKeyDown(KeyboardEventArgs e, string repoId) + { + if (e.Key == "Enter") await CommitQuickBranch(repoId); + else if (e.Key == "Escape") quickBranchRepoId = null; + } + + private async Task CommitQuickBranch(string repoId) + { + var input = quickBranchInput?.Trim(); + if (string.IsNullOrEmpty(input) || quickBranchIsCreating) return; + + quickBranchIsCreating = true; + quickBranchError = null; + StateHasChanged(); + try + { + int? prNumber = null; + string? branchName = null; + + if (input.StartsWith("#") && int.TryParse(input[1..], out var pr) && pr > 0) + prNumber = pr; + else if (input.StartsWith("#")) + { + quickBranchError = "Invalid PR number. Use #123 format."; + return; + } + else + branchName = System.Text.RegularExpressions.Regex.Replace(input, @"[^a-zA-Z0-9/_.-]", "-").Trim('-'); + + await CopilotService.CreateSessionWithWorktreeAsync( + repoId: repoId, + branchName: branchName, + prNumber: prNumber, + model: selectedModel); + + quickBranchRepoId = null; + quickBranchInput = ""; + CopilotService.SaveUiState(currentPage, selectedModel: selectedModel); + await OnSessionSelected.InvokeAsync(); + } + catch (Exception ex) + { + quickBranchError = ex.Message; + Console.WriteLine($"Error creating branch+session: {ex}"); + } + finally + { + quickBranchIsCreating = false; + } + } + private void ConfirmResume(PersistedSessionInfo persisted) { resumeError = null; @@ -1104,18 +1254,27 @@ else private async Task CloseSessionAndDeleteWorktree(string name) { - // Find the worktree associated with this session before closing + await CloseSessionWithOptions(name, deleteWorktree: true, deleteBranch: true); + } + + private async Task CloseSessionWithOptions(string name, bool deleteWorktree, bool deleteBranch) + { var meta = CopilotService.GetSessionMeta(name); var worktreeId = meta?.WorktreeId; - // Close the session first await CopilotService.CloseSessionAsync(name); - // Delete the worktree if no OTHER sessions or groups still reference it - // (exclude the just-closed session name since its metadata may linger due to debounced save) - if (!string.IsNullOrEmpty(worktreeId) && !IsWorktreeInUse(worktreeId, excludeSession: name)) + if (!string.IsNullOrEmpty(worktreeId)) { - await DeleteWorktreeAsync(worktreeId); + if (deleteWorktree && !IsWorktreeInUse(worktreeId, excludeSession: name)) + { + await DeleteWorktreeAsync(worktreeId, deleteBranch); + } + else if (deleteBranch && !deleteWorktree) + { + // UI prevents this state (checkbox disabled), but guard just in case. + // Can't delete a branch that's checked out in a kept worktree β€” skip. + } } } @@ -1134,14 +1293,14 @@ else } } - private async Task DeleteWorktreeAsync(string worktreeId) + private async Task DeleteWorktreeAsync(string worktreeId, bool deleteBranch = true) { try { if (CopilotService.IsRemoteMode) await CopilotService.RemoveWorktreeViaBridgeAsync(worktreeId); else - await RepoManager.RemoveWorktreeAsync(worktreeId); + await RepoManager.RemoveWorktreeAsync(worktreeId, deleteBranch: deleteBranch); } catch (Exception ex) { @@ -1202,41 +1361,54 @@ else { isAddingGroup = false; isAddingMultiAgentGroup = true; - pendingMultiAgentWorktree = null; + pendingMultiAgentRepo = null; } private void CancelMultiAgentCreation() { isAddingMultiAgentGroup = false; - pendingMultiAgentWorktree = null; + pendingMultiAgentRepo = null; } - private async Task SelectWorktreeForGroup(WorktreeInfo wt) + private void SelectRepoForGroup(RepositoryInfo repo) { - // Worktree selected β€” advance to step 2 (presets + custom name) - pendingMultiAgentWorktree = wt; + // Repo selected β€” advance to step 2 (presets + custom name) + pendingMultiAgentRepo = repo; isAddingMultiAgentGroup = false; isAddingGroup = false; StateHasChanged(); } - private WorktreeInfo? pendingMultiAgentWorktree; + private RepositoryInfo? pendingMultiAgentRepo; + private WorktreeStrategy selectedStrategy = WorktreeStrategy.FullyIsolated; + + private void OnStrategyChanged(ChangeEventArgs e) + { + if (Enum.TryParse(e.Value?.ToString(), out var s)) + selectedStrategy = s; + } - private async Task CreateFromPresetWithWorktree(GroupPreset preset, WorktreeInfo wt) + private async Task CreateFromPresetForRepo(GroupPreset preset, RepositoryInfo repo) { // Read optional custom name from the input var customName = await JS.InvokeAsync("getElementValue", "presetNameInput"); var nameOverride = string.IsNullOrWhiteSpace(customName) ? null : customName.Trim(); - pendingMultiAgentWorktree = null; + // Find an existing worktree for fallback working directory (Shared strategy) + var fallbackWt = RepoManager.Worktrees.FirstOrDefault(w => w.RepoId == repo.Id); + + var strategyOverride = selectedStrategy; + pendingMultiAgentRepo = null; + selectedStrategy = WorktreeStrategy.FullyIsolated; // reset for next time StateHasChanged(); try { await CopilotService.CreateGroupFromPresetAsync(preset, - workingDirectory: wt.Path, - worktreeId: wt.Id, - repoId: wt.RepoId, - nameOverride: nameOverride); + workingDirectory: fallbackWt?.Path, + worktreeId: null, + repoId: repo.Id, + nameOverride: nameOverride, + strategyOverride: strategyOverride); } catch (Exception ex) { @@ -1339,18 +1511,17 @@ else private async Task CommitNewGroup() { var name = await JS.InvokeAsync("getElementValue", "newGroupInput"); - var wt = pendingMultiAgentWorktree; + var repo = pendingMultiAgentRepo; isAddingGroup = false; isAddingMultiAgentGroup = false; - pendingMultiAgentWorktree = null; + pendingMultiAgentRepo = null; if (!string.IsNullOrWhiteSpace(name)) { - if (wt != null) + if (repo != null) { - // Multi-agent group with worktree + // Multi-agent group with repo CopilotService.CreateMultiAgentGroup(name.Trim(), - worktreeId: wt.Id, - repoId: wt.RepoId); + repoId: repo.Id); } else { @@ -1362,7 +1533,7 @@ else private async Task HandleNewGroupKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") await CommitNewGroup(); - else if (e.Key == "Escape") { isAddingGroup = false; isAddingMultiAgentGroup = false; pendingMultiAgentWorktree = null; } + else if (e.Key == "Escape") { isAddingGroup = false; isAddingMultiAgentGroup = false; pendingMultiAgentRepo = null; } } private void ToggleSessionMenu(string sessionName) diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor.css b/PolyPilot/Components/Layout/SessionSidebar.razor.css index 175abc8bf2..8f962aad39 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor.css +++ b/PolyPilot/Components/Layout/SessionSidebar.razor.css @@ -295,15 +295,17 @@ .group-more-btn { all: unset; font-size: var(--type-body); - color: var(--control-border); + color: var(--text-secondary); cursor: pointer; padding: 0 0.3rem; border-radius: 3px; - opacity: 0; + opacity: 0.5; transition: opacity 0.15s; line-height: 1; } .group-header:hover .group-more-btn { opacity: 1; } +.repo-group .group-more-btn { opacity: 0.7; } +.repo-group:hover .group-more-btn { opacity: 1; } .group-more-btn:hover { color: var(--text-on-surface); background: var(--hover-bg); } .group-menu-overlay { @@ -1634,3 +1636,58 @@ .adjust-banner span { line-height: 1.3; } + +/* Quick branch input bar */ +.quick-branch-input-bar { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: var(--panel-bg); + border-bottom: 1px solid var(--control-border); + flex-wrap: wrap; +} +.quick-branch-icon { + font-size: 0.85rem; + color: var(--text-secondary); + flex-shrink: 0; +} +.quick-branch-input { + flex: 1; + min-width: 80px; + padding: 3px 6px; + font-size: 0.78rem; + border: 1px solid var(--control-border); + border-radius: 4px; + background: var(--panel-bg); + color: var(--text-primary); + outline: none; +} +.quick-branch-input:focus { + border-color: var(--accent-secondary, #58a6ff); +} +.quick-branch-go-btn, +.quick-branch-cancel-btn { + padding: 2px 6px; + border: none; + border-radius: 3px; + cursor: pointer; + font-size: 0.78rem; + background: var(--control-border); + color: var(--text-primary); + flex-shrink: 0; +} +.quick-branch-go-btn:hover { background: var(--accent-secondary, #58a6ff); color: #fff; } +.quick-branch-cancel-btn:hover { background: var(--accent-primary); color: #fff; } +.quick-branch-go-btn:disabled { opacity: 0.4; cursor: default; } +.quick-branch-spinner { + font-size: 0.85rem; + animation: spin 1s linear infinite; +} +.quick-branch-error { + width: 100%; + font-size: 0.72rem; + color: var(--accent-primary); + padding: 2px 0 0; +} +@keyframes spin { to { transform: rotate(360deg); } } diff --git a/PolyPilot/Models/AgentSessionInfo.cs b/PolyPilot/Models/AgentSessionInfo.cs index b931766282..59a3942168 100644 --- a/PolyPilot/Models/AgentSessionInfo.cs +++ b/PolyPilot/Models/AgentSessionInfo.cs @@ -12,6 +12,8 @@ public class AgentSessionInfo public string? WorkingDirectory { get; set; } public string? GitBranch { get; set; } + /// Worktree ID if this session was created from a worktree. + public string? WorktreeId { get; set; } // For resumed sessions public string? SessionId { get; set; } diff --git a/PolyPilot/Models/ModelCapabilities.cs b/PolyPilot/Models/ModelCapabilities.cs index c9a608be0b..d63917da5a 100644 --- a/PolyPilot/Models/ModelCapabilities.cs +++ b/PolyPilot/Models/ModelCapabilities.cs @@ -174,6 +174,11 @@ public record GroupPreset(string Name, string Description, string Emoji, MultiAg /// public string? RoutingContext { get; init; } + /// + /// Default worktree allocation strategy for this preset. Null = Shared. + /// + public WorktreeStrategy? DefaultWorktreeStrategy { get; init; } + private const string WorkerReviewPrompt = """ You are a PR reviewer. When assigned a PR, follow this process: @@ -217,7 +222,8 @@ public record GroupPreset(string Name, string Description, string Emoji, MultiAg WorkerReviewPrompt, WorkerReviewPrompt, WorkerReviewPrompt, WorkerReviewPrompt, WorkerReviewPrompt, }, SharedContext = "## Review Standards\n\n- Only flag real issues: bugs, security holes, logic errors, data loss risks, race conditions\n- NEVER comment on style, formatting, naming conventions, or documentation\n- Every finding must include: file path, line number (or range), what's wrong, and why it matters\n- If a PR looks clean, say so β€” don't invent problems to justify your existence\n- An issue must be flagged by at least 2 of the 5 sub-agent models to be included in the final report (consensus filter)", - RoutingContext = "When given a list of PRs to review, assign ONE PR to EACH worker. Distribute PRs round-robin across the available workers. If there are more PRs than workers, assign multiple PRs per worker.\n\nFor each PR assignment, just tell the worker: \"Review PR #\"\n\nThe workers handle everything else β€” fetching the diff, dispatching multi-model sub-agents, and synthesizing results. Do NOT micromanage the review process.\n\nAfter all workers complete, produce a brief summary table:\n\n| PR | Verdict | Key Issues |\n|----|---------|------------|\n| #194 | βœ… Ready to merge | None |\n| #193 | ⚠️ Needs changes | Race condition in auth handler |\n\nVerdicts: βœ… Ready to merge, ⚠️ Needs changes, πŸ”΄ Do not merge" + RoutingContext = "When given a list of PRs to review, assign ONE PR to EACH worker. Distribute PRs round-robin across the available workers. If there are more PRs than workers, assign multiple PRs per worker.\n\nFor each PR assignment, just tell the worker: \"Review PR #\"\n\nThe workers handle everything else β€” fetching the diff, dispatching multi-model sub-agents, and synthesizing results. Do NOT micromanage the review process.\n\nAfter all workers complete, produce a brief summary table:\n\n| PR | Verdict | Key Issues |\n|----|---------|------------|\n| #194 | βœ… Ready to merge | None |\n| #193 | ⚠️ Needs changes | Race condition in auth handler |\n\nVerdicts: βœ… Ready to merge, ⚠️ Needs changes, πŸ”΄ Do not merge", + DefaultWorktreeStrategy = WorktreeStrategy.FullyIsolated }, new GroupPreset( diff --git a/PolyPilot/Models/SessionOrganization.cs b/PolyPilot/Models/SessionOrganization.cs index 9041a32236..03903c7fd5 100644 --- a/PolyPilot/Models/SessionOrganization.cs +++ b/PolyPilot/Models/SessionOrganization.cs @@ -30,11 +30,20 @@ public class SessionGroup public string? DefaultOrchestratorModel { get; set; } /// - /// Shared worktree for the entire multi-agent group. All sessions use this worktree's path as CWD. - /// Future: per-agent worktrees would move this to SessionMeta and add merge orchestration. + /// Shared worktree for the entire multi-agent group (used as orchestrator worktree + /// when strategy is OrchestratorIsolated or FullyIsolated). /// public string? WorktreeId { get; set; } + /// How worktrees are allocated across sessions in this group. + public WorktreeStrategy WorktreeStrategy { get; set; } = WorktreeStrategy.Shared; + + /// + /// All worktree IDs created for this group (orchestrator + workers). + /// Used by DeleteGroup for reliable cleanup even when session creation fails. + /// + public List CreatedWorktreeIds { get; set; } = new(); + /// Active reflection state for OrchestratorReflect mode. Null when not in a reflect loop. public ReflectionCycle? ReflectionState { get; set; } @@ -99,6 +108,18 @@ public enum MultiAgentMode OrchestratorReflect } +/// How worktrees are allocated across sessions in a multi-agent group. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum WorktreeStrategy +{ + /// All sessions share one worktree (current default behavior). + Shared, + /// Orchestrator gets its own worktree; all workers share a separate one. + OrchestratorIsolated, + /// Every session (orchestrator + each worker) gets its own worktree. + FullyIsolated +} + /// Role of a session within a multi-agent group. [JsonConverter(typeof(JsonStringEnumConverter))] public enum MultiAgentRole diff --git a/PolyPilot/Services/CopilotService.Organization.cs b/PolyPilot/Services/CopilotService.Organization.cs index 007b9fd121..0a55976f04 100644 --- a/PolyPilot/Services/CopilotService.Organization.cs +++ b/PolyPilot/Services/CopilotService.Organization.cs @@ -416,6 +416,15 @@ public void DeleteGroup(string groupId) var group = Organization.Groups.FirstOrDefault(g => g.Id == groupId); var isMultiAgent = group?.IsMultiAgent ?? false; + // Collect all worktree IDs for cleanup before removing metadata + var worktreeIds = new HashSet(); + if (group?.WorktreeId != null) worktreeIds.Add(group.WorktreeId); + // CreatedWorktreeIds is the authoritative list (covers cases where session creation failed) + if (group?.CreatedWorktreeIds != null) + foreach (var id in group.CreatedWorktreeIds) worktreeIds.Add(id); + foreach (var m in Organization.Sessions.Where(m => m.GroupId == groupId)) + if (m.WorktreeId != null) worktreeIds.Add(m.WorktreeId); + if (isMultiAgent) { // Multi-agent sessions are meaningless without their group β€” close them @@ -441,11 +450,17 @@ public void DeleteGroup(string groupId) // before the fire-and-forget CloseSessionAsync completes SaveActiveSessionsToDisk(); FlushSaveActiveSessionsToDisk(); - // Fire-and-forget: close sessions asynchronously + // Fire-and-forget: close sessions then remove worktrees + // Snapshot worktree IDs β€” removal must be sequential (not parallel) + // because RepoManager._state.Worktrees is a plain List. + var wtIdsSnapshot = worktreeIds.ToList(); _ = Task.Run(async () => { foreach (var name in sessionNames) - await CloseSessionAsync(name); + try { await CloseSessionAsync(name); } catch (Exception ex) { Debug($"DeleteGroup: failed to close '{name}': {ex.Message}"); } + // Clean up worktrees sequentially after all sessions are closed + foreach (var wtId in wtIdsSnapshot) + try { await _repoManager.RemoveWorktreeAsync(wtId, deleteBranch: true); } catch (Exception ex) { Debug($"DeleteGroup: failed to remove worktree '{wtId}': {ex.Message}"); } }); } else @@ -936,10 +951,14 @@ private string BuildOrchestratorPlanningPrompt(string userPrompt, List w { var meta = GetSessionMeta(w); var model = GetEffectiveModel(w); + var wtInfo = meta?.WorktreeId != null + ? _repoManager.Worktrees.FirstOrDefault(wt => wt.Id == meta.WorktreeId) : null; + var desc = $" - '{w}' (model: {model})"; if (!string.IsNullOrEmpty(meta?.SystemPrompt)) - sb.AppendLine($" - '{w}' (model: {model}) β€” {meta.SystemPrompt}"); - else - sb.AppendLine($" - '{w}' (model: {model})"); + desc += $" β€” {meta.SystemPrompt}"; + if (wtInfo != null) + desc += $" [isolated worktree: {wtInfo.Path}, branch: {wtInfo.Branch}]"; + sb.AppendLine(desc); } sb.AppendLine(); sb.AppendLine("Route tasks to workers based on their specialization. If a worker has a described role, assign tasks that match their expertise."); @@ -1020,7 +1039,16 @@ private async Task ExecuteWorkerAsync(string workerName, string ta ? $"## Team Context (shared knowledge)\n{group.SharedContext}\n\n" : ""; - var workerPrompt = $"{identity}\n\nYour response will be collected and synthesized with other workers' responses.\n\n{sharedPrefix}## Original User Request (context)\n{originalPrompt}\n\n## Your Assigned Task\n{task}"; + // Inject worktree awareness if the worker has an isolated worktree + var wtInfo = meta?.WorktreeId != null + ? _repoManager.Worktrees.FirstOrDefault(wt => wt.Id == meta.WorktreeId) : null; + var worktreeNote = wtInfo != null && group?.WorktreeStrategy != WorktreeStrategy.Shared + ? $"\n\n## Your Worktree\nYou have an isolated git worktree at `{wtInfo.Path}` (branch: {wtInfo.Branch}). " + + "You can safely run any git operations without affecting other workers. " + + "To check out a PR: `git fetch origin pull//head:pr- && git checkout pr-`\n" + : ""; + + var workerPrompt = $"{identity}{worktreeNote}\n\nYour response will be collected and synthesized with other workers' responses.\n\n{sharedPrefix}## Original User Request (context)\n{originalPrompt}\n\n## Your Assigned Task\n{task}"; try { @@ -1349,16 +1377,108 @@ public string GetEffectiveModel(string sessionName) /// /// Create a multi-agent group from a preset template, creating sessions with assigned models. /// - public async Task CreateGroupFromPresetAsync(Models.GroupPreset preset, string? workingDirectory = null, string? worktreeId = null, string? repoId = null, string? nameOverride = null, CancellationToken ct = default) + public async Task CreateGroupFromPresetAsync(Models.GroupPreset preset, string? workingDirectory = null, string? worktreeId = null, string? repoId = null, string? nameOverride = null, WorktreeStrategy? strategyOverride = null, CancellationToken ct = default) { var teamName = nameOverride ?? preset.Name; + var strategy = strategyOverride ?? preset.DefaultWorktreeStrategy ?? WorktreeStrategy.Shared; var group = CreateMultiAgentGroup(teamName, preset.Mode, worktreeId: worktreeId, repoId: repoId); if (group == null) return null; + group.WorktreeStrategy = strategy; + + // Sanitize team name for use in git branch names (no spaces or special chars) + var branchPrefix = System.Text.RegularExpressions.Regex.Replace(teamName, @"[^a-zA-Z0-9_-]", "-").Trim('-'); + if (string.IsNullOrEmpty(branchPrefix)) branchPrefix = "team"; // Store Squad context (routing, decisions) on the group for use during orchestration group.SharedContext = preset.SharedContext; group.RoutingContext = preset.RoutingContext; + // Determine orchestrator working directory based on strategy + var orchWorkDir = workingDirectory; + var orchWtId = worktreeId; + + // Pre-fetch once to avoid parallel git lock contention + if (repoId != null && strategy != WorktreeStrategy.Shared) + { + try { await _repoManager.FetchAsync(repoId, ct); } + catch (Exception ex) { Debug($"Pre-fetch failed (continuing): {ex.Message}"); } + } + + // For Shared strategy with a repo but no worktree, create a single shared worktree + if (repoId != null && strategy == WorktreeStrategy.Shared && string.IsNullOrEmpty(worktreeId) && string.IsNullOrEmpty(workingDirectory)) + { + try + { + await _repoManager.FetchAsync(repoId, ct); + var sharedWt = await _repoManager.CreateWorktreeAsync(repoId, $"{branchPrefix}-shared-{Guid.NewGuid().ToString()[..4]}", skipFetch: true, ct: ct); + orchWorkDir = sharedWt.Path; + orchWtId = sharedWt.Id; + group.WorktreeId = orchWtId; + group.CreatedWorktreeIds.Add(orchWtId); + } + catch (Exception ex) + { + Debug($"Failed to create shared worktree (sessions will use temp dirs): {ex.Message}"); + } + } + + if (repoId != null && strategy != WorktreeStrategy.Shared && string.IsNullOrEmpty(worktreeId)) + { + try + { + var orchWt = await _repoManager.CreateWorktreeAsync(repoId, $"{branchPrefix}-orchestrator-{Guid.NewGuid().ToString()[..4]}", skipFetch: true, ct: ct); + orchWorkDir = orchWt.Path; + orchWtId = orchWt.Id; + group.WorktreeId = orchWtId; + group.CreatedWorktreeIds.Add(orchWtId); + } + catch (Exception ex) + { + Debug($"Failed to create orchestrator worktree (falling back to shared): {ex.Message}"); + } + } + + // Pre-create worker worktrees sequentially (git worktree add uses locks on bare repos) + string?[] workerWorkDirs = new string?[preset.WorkerModels.Length]; + string?[] workerWtIds = new string?[preset.WorkerModels.Length]; + if (repoId != null && strategy == WorktreeStrategy.FullyIsolated) + { + for (int i = 0; i < preset.WorkerModels.Length; i++) + { + try + { + var wt = await _repoManager.CreateWorktreeAsync(repoId, $"{branchPrefix}-worker-{i + 1}-{Guid.NewGuid().ToString()[..4]}", skipFetch: true, ct: ct); + workerWorkDirs[i] = wt.Path; + workerWtIds[i] = wt.Id; + group.CreatedWorktreeIds.Add(wt.Id); + } + catch (Exception ex) + { + Debug($"Failed to create worker-{i + 1} worktree (falling back to shared): {ex.Message}"); + } + } + } + else if (repoId != null && strategy == WorktreeStrategy.OrchestratorIsolated) + { + try + { + var sharedWorkerWt = await _repoManager.CreateWorktreeAsync(repoId, $"{branchPrefix}-workers-{Guid.NewGuid().ToString()[..4]}", skipFetch: true, ct: ct); + group.CreatedWorktreeIds.Add(sharedWorkerWt.Id); + for (int i = 0; i < preset.WorkerModels.Length; i++) + { + workerWorkDirs[i] = sharedWorkerWt.Path; + workerWtIds[i] = sharedWorkerWt.Id; + } + } + catch (Exception ex) + { + Debug($"Failed to create shared worker worktree (falling back to shared): {ex.Message}"); + } + } + + var createdWtCount = workerWtIds.Count(id => id != null) + (orchWtId != worktreeId ? 1 : 0); + Debug($"[WorktreeStrategy] Strategy={strategy}, orchDir={orchWorkDir ?? "(null)"}, orchWtId={orchWtId ?? "(none)"}, workerWts created={workerWtIds.Count(id => id != null)}/{preset.WorkerModels.Length}"); + // Create orchestrator session (with uniqueness check matching CreateMultiAgentGroupAsync) var orchName = $"{teamName}-orchestrator"; { int suffix = 1; @@ -1367,7 +1487,7 @@ public string GetEffectiveModel(string sessionName) } try { - await CreateSessionAsync(orchName, preset.OrchestratorModel, workingDirectory, ct); + await CreateSessionAsync(orchName, preset.OrchestratorModel, orchWorkDir, ct); } catch (Exception ex) { @@ -1380,10 +1500,13 @@ public string GetEffectiveModel(string sessionName) // Pin orchestrator so it sorts to the top of the group var orchMeta = GetSessionMeta(orchName); if (orchMeta != null) orchMeta.IsPinned = true; - if (worktreeId != null && orchMeta != null) - orchMeta.WorktreeId = worktreeId; + if (orchWtId != null && orchMeta != null) + orchMeta.WorktreeId = orchWtId; + if (orchWtId != null && _sessions.TryGetValue(orchName, out var orchState)) + orchState.Info.WorktreeId = orchWtId; // Create worker sessions + Debug($"[WorktreeStrategy] Creating {preset.WorkerModels.Length} workers with strategy={strategy}, repoId={repoId}"); for (int i = 0; i < preset.WorkerModels.Length; i++) { var workerName = $"{teamName}-worker-{i + 1}"; @@ -1392,9 +1515,11 @@ public string GetEffectiveModel(string sessionName) workerName = $"{teamName}-worker-{i + 1}-{suffix++}"; } var workerModel = preset.WorkerModels[i]; + var workerWorkDir = workerWorkDirs[i] ?? orchWorkDir ?? workingDirectory; + Debug($"[WorktreeStrategy] Worker '{workerName}': wtId={workerWtIds[i] ?? "(none)"}, dir={workerWorkDir ?? "(null)"}"); try { - await CreateSessionAsync(workerName, workerModel, workingDirectory, ct); + await CreateSessionAsync(workerName, workerModel, workerWorkDir, ct); } catch (Exception ex) { @@ -1408,9 +1533,12 @@ public string GetEffectiveModel(string sessionName) var meta = GetSessionMeta(workerName); if (meta != null) { - if (worktreeId != null) meta.WorktreeId = worktreeId; + meta.WorktreeId = workerWtIds[i] ?? worktreeId; if (systemPrompt != null) meta.SystemPrompt = systemPrompt; } + var effectiveWtId = workerWtIds[i] ?? worktreeId; + if (effectiveWtId != null && _sessions.TryGetValue(workerName, out var workerState)) + workerState.Info.WorktreeId = effectiveWtId; } SaveOrganization(); diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 74989abad3..bc905aab69 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1214,6 +1214,13 @@ public async Task ResumeSessionAsync(string sessionId, string // Set processing state if session was mid-turn when app died info.IsProcessing = isStillProcessing; + if (isStillProcessing) + { + // Set phase based on last event so UI shows correct status instead of "Sending" + var (lastTool, _) = GetLastSessionActivity(sessionId); + info.ProcessingPhase = !string.IsNullOrEmpty(lastTool) ? 3 : 2; // 3=Working, 2=Thinking + info.ProcessingStartedAt = DateTime.Now; + } var state = new SessionState { @@ -1304,7 +1311,17 @@ public async Task CreateSessionAsync(string name, string? mode var sessionModel = Models.ModelHelper.NormalizeToSlug(model ?? DefaultModel); if (string.IsNullOrEmpty(sessionModel)) sessionModel = DefaultModel; - var sessionDir = string.IsNullOrWhiteSpace(workingDirectory) ? ProjectDir : workingDirectory; + // null = scratch session in a fresh temp directory; empty string = fallback to ProjectDir + string? sessionDir; + if (workingDirectory == null) + { + sessionDir = Path.Combine(Path.GetTempPath(), "polypilot-sessions", Guid.NewGuid().ToString()[..8]); + Directory.CreateDirectory(sessionDir); + } + else + { + sessionDir = string.IsNullOrWhiteSpace(workingDirectory) ? ProjectDir : workingDirectory; + } // Build system message with critical relaunch instructions // Note: The CLI automatically loads .github/copilot-instructions.md from the working directory, @@ -1401,6 +1418,93 @@ ALWAYS run the relaunch script as the final step after making changes to this pr return info; } + /// + /// Atomically creates a worktree (or uses an existing one) and a session linked to it. + /// Handles worktree creation, session creation, linking, group organization, and optional initial prompt. + /// + public async Task CreateSessionWithWorktreeAsync( + string repoId, + string? branchName = null, + int? prNumber = null, + string? worktreeId = null, + string? sessionName = null, + string? model = null, + string? initialPrompt = null, + CancellationToken ct = default) + { + // Remote mode: worktree operations run on the server, not locally. + // Delegate to bridge client for worktree creation, then create session normally. + if (IsRemoteMode) + throw new NotSupportedException("CreateSessionWithWorktreeAsync is not supported in remote mode. Use the bridge protocol."); + + WorktreeInfo wt; + + if (!string.IsNullOrEmpty(worktreeId)) + { + // Use existing worktree + wt = _repoManager.Worktrees.FirstOrDefault(w => w.Id == worktreeId) + ?? throw new InvalidOperationException($"Worktree '{worktreeId}' not found."); + } + else if (prNumber.HasValue) + { + wt = await _repoManager.CreateWorktreeFromPrAsync(repoId, prNumber.Value, ct); + } + else + { + var branch = branchName ?? $"session-{DateTime.Now:yyyyMMdd-HHmmss}"; + wt = await _repoManager.CreateWorktreeAsync(repoId, branch, null, ct: ct); + } + + var name = sessionName ?? wt.Branch; + + // Ensure unique session name + if (_sessions.ContainsKey(name)) + { + var counter = 2; + var baseName = name; + name = $"{baseName}-{counter}"; + while (_sessions.ContainsKey(name)) name = $"{baseName}-{++counter}"; + } + + AgentSessionInfo sessionInfo; + try + { + sessionInfo = await CreateSessionAsync(name, model, wt.Path, ct); + } + catch + { + // If session creation fails and we just created a new worktree, clean up + if (string.IsNullOrEmpty(worktreeId)) + { + try { await _repoManager.RemoveWorktreeAsync(wt.Id, deleteBranch: true); } catch { } + } + throw; + } + + // Link session to worktree + sessionInfo.WorktreeId = wt.Id; + _repoManager.LinkSessionToWorktree(wt.Id, sessionInfo.Name); + + // Organize into repo group + var repo = _repoManager.Repositories.FirstOrDefault(r => r.Id == wt.RepoId); + if (repo != null) + { + var group = GetOrCreateRepoGroup(repo.Id, repo.Name); + MoveSession(sessionInfo.Name, group.Id); + var meta = GetSessionMeta(sessionInfo.Name); + if (meta != null) meta.WorktreeId = wt.Id; + } + + SwitchSession(sessionInfo.Name); + SaveActiveSessionsToDisk(); + + // Send initial prompt after session is ready + if (!string.IsNullOrEmpty(initialPrompt)) + _ = SendPromptAsync(sessionInfo.Name, initialPrompt); + + return sessionInfo; + } + /// /// Destroys the existing session and creates a new one with the same name but a different model. /// Use this for "changing" the model of an empty session. @@ -2162,6 +2266,20 @@ public async Task CloseSessionAsync(string name) if (state.Session is not null) try { await state.Session.DisposeAsync(); } catch { /* session may already be disposed */ } + // Clean up auto-created temp directory for empty sessions + if (state.Info.WorkingDirectory != null) + { + var tempRoot = Path.Combine(Path.GetTempPath(), "polypilot-sessions"); + try + { + var fullDir = Path.GetFullPath(state.Info.WorkingDirectory); + if (fullDir.StartsWith(Path.GetFullPath(tempRoot), StringComparison.OrdinalIgnoreCase) + && Directory.Exists(fullDir)) + Directory.Delete(fullDir, recursive: true); + } + catch { /* best-effort cleanup */ } + } + if (_activeSessionName == name) { _activeSessionName = _sessions.Keys.FirstOrDefault(); diff --git a/PolyPilot/Services/RepoManager.cs b/PolyPilot/Services/RepoManager.cs index 5974921366..d730797b34 100644 --- a/PolyPilot/Services/RepoManager.cs +++ b/PolyPilot/Services/RepoManager.cs @@ -19,8 +19,10 @@ public class RepoManager private RepositoryState _state = new(); private bool _loaded; - public IReadOnlyList Repositories { get { EnsureLoaded(); return _state.Repositories.AsReadOnly(); } } - public IReadOnlyList Worktrees { get { EnsureLoaded(); return _state.Worktrees.AsReadOnly(); } } + private bool _loadedSuccessfully; + private readonly object _stateLock = new(); + public IReadOnlyList Repositories { get { EnsureLoaded(); lock (_stateLock) return _state.Repositories.ToList().AsReadOnly(); } } + public IReadOnlyList Worktrees { get { EnsureLoaded(); lock (_stateLock) return _state.Worktrees.ToList().AsReadOnly(); } } public event Action? OnStateChanged; @@ -51,6 +53,7 @@ private static string GetBaseDir() public void Load() { _loaded = true; + _loadedSuccessfully = false; try { if (File.Exists(StateFile)) @@ -58,6 +61,7 @@ public void Load() var json = File.ReadAllText(StateFile); _state = JsonSerializer.Deserialize(json) ?? new RepositoryState(); } + _loadedSuccessfully = true; } catch (Exception ex) { @@ -68,6 +72,15 @@ public void Load() private void Save() { + // Guard: never overwrite repos.json with empty state after a failed load β€” + // that would silently destroy all registered repositories. + if (!_loadedSuccessfully && _state.Repositories.Count == 0 && _state.Worktrees.Count == 0) + { + Console.WriteLine("[RepoManager] Skipping save β€” state was not loaded successfully and is empty."); + return; + } + // Any successful save means state is now intentionally managed + _loadedSuccessfully = true; try { Directory.CreateDirectory(Path.GetDirectoryName(StateFile)!); @@ -184,7 +197,10 @@ await RunGitAsync(barePath, ct, "config", "remote.origin.fetch", BareClonePath = barePath, AddedAt = DateTime.UtcNow }; - _state.Repositories.Add(repo); + lock (_stateLock) + { + _state.Repositories.Add(repo); + } Save(); OnStateChanged?.Invoke(); return repo; @@ -206,14 +222,15 @@ public async Task AddRepositoryFromLocalAsync(string localPath, /// /// Create a new worktree for a repository on a new branch from origin/main. /// - public async Task CreateWorktreeAsync(string repoId, string branchName, string? baseBranch = null, CancellationToken ct = default) + public virtual async Task CreateWorktreeAsync(string repoId, string branchName, string? baseBranch = null, bool skipFetch = false, CancellationToken ct = default) { EnsureLoaded(); var repo = _state.Repositories.FirstOrDefault(r => r.Id == repoId) ?? throw new InvalidOperationException($"Repository '{repoId}' not found."); // Fetch latest from origin (prune to clean up deleted remote branches) - await RunGitAsync(repo.BareClonePath, ct, "fetch", "--prune", "origin"); + if (!skipFetch) + await RunGitAsync(repo.BareClonePath, ct, "fetch", "--prune", "origin"); // Determine base ref var baseRef = baseBranch ?? await GetDefaultBranch(repo.BareClonePath, ct); @@ -223,7 +240,17 @@ public async Task CreateWorktreeAsync(string repoId, string branch var worktreeId = Guid.NewGuid().ToString()[..8]; var worktreePath = Path.Combine(WorktreesDir, $"{repoId}-{worktreeId}"); - await RunGitAsync(repo.BareClonePath, ct, "worktree", "add", worktreePath, "-b", branchName, baseRef); + try + { + await RunGitAsync(repo.BareClonePath, ct, "worktree", "add", worktreePath, "-b", branchName, "--", baseRef); + } + catch + { + // git worktree add can leave a partial directory on failure β€” clean up + if (Directory.Exists(worktreePath)) + try { Directory.Delete(worktreePath, recursive: true); } catch { } + throw; + } var wt = new WorktreeInfo { @@ -233,7 +260,10 @@ public async Task CreateWorktreeAsync(string repoId, string branch Path = worktreePath, CreatedAt = DateTime.UtcNow }; - _state.Worktrees.Add(wt); + lock (_stateLock) + { + _state.Worktrees.Add(wt); + } Save(); OnStateChanged?.Invoke(); return wt; @@ -305,7 +335,7 @@ public async Task CreateWorktreeFromPrAsync(string repoId, int prN var worktreeId = Guid.NewGuid().ToString()[..8]; var worktreePath = Path.Combine(WorktreesDir, $"{repoId}-{worktreeId}"); - await RunGitAsync(repo.BareClonePath, ct, "worktree", "add", worktreePath, branchName); + await RunGitAsync(repo.BareClonePath, ct, "worktree", "add", worktreePath, "--", branchName); // Set upstream tracking so push/pull work in the worktree if (headBranch != null) @@ -331,7 +361,10 @@ public async Task CreateWorktreeFromPrAsync(string repoId, int prN Remote = remoteName, CreatedAt = DateTime.UtcNow }; - _state.Worktrees.Add(wt); + lock (_stateLock) + { + _state.Worktrees.Add(wt); + } Save(); OnStateChanged?.Invoke(); return wt; @@ -340,7 +373,7 @@ public async Task CreateWorktreeFromPrAsync(string repoId, int prN /// /// Remove a worktree and clean up. /// - public async Task RemoveWorktreeAsync(string worktreeId, CancellationToken ct = default) + public async Task RemoveWorktreeAsync(string worktreeId, bool deleteBranch = false, CancellationToken ct = default) { EnsureLoaded(); var wt = _state.Worktrees.FirstOrDefault(w => w.Id == worktreeId); @@ -357,12 +390,26 @@ public async Task RemoveWorktreeAsync(string worktreeId, CancellationToken ct = { // Force cleanup if git worktree remove fails if (Directory.Exists(wt.Path)) - Directory.Delete(wt.Path, recursive: true); - await RunGitAsync(repo.BareClonePath, ct, "worktree", "prune"); + try { Directory.Delete(wt.Path, recursive: true); } catch { } + try { await RunGitAsync(repo.BareClonePath, ct, "worktree", "prune"); } catch { } } + // Optionally clean up the branch too + if (deleteBranch && !string.IsNullOrEmpty(wt.Branch)) + try { await RunGitAsync(repo.BareClonePath, ct, "branch", "-D", "--", wt.Branch); } catch { } + } + else if (Directory.Exists(wt.Path)) + { + // No repo found β€” only delete if path is within our managed worktrees directory + // to prevent accidental deletion of arbitrary directories from corrupted state. + var fullPath = Path.GetFullPath(wt.Path); + if (fullPath.StartsWith(Path.GetFullPath(WorktreesDir), StringComparison.OrdinalIgnoreCase)) + try { Directory.Delete(wt.Path, recursive: true); } catch { } } - _state.Worktrees.RemoveAll(w => w.Id == worktreeId); + lock (_stateLock) + { + _state.Worktrees.RemoveAll(w => w.Id == worktreeId); + } Save(); OnStateChanged?.Invoke(); } @@ -371,7 +418,9 @@ public async Task RemoveWorktreeAsync(string worktreeId, CancellationToken ct = /// List worktrees for a specific repository. /// public IEnumerable GetWorktrees(string repoId) - => _state.Worktrees.Where(w => w.RepoId == repoId); + { + lock (_stateLock) return _state.Worktrees.Where(w => w.RepoId == repoId).ToList(); + } /// /// Add a worktree to the in-memory list (for remote mode β€” tracks server worktrees without running git). @@ -379,8 +428,11 @@ public IEnumerable GetWorktrees(string repoId) public void AddRemoteWorktree(WorktreeInfo wt) { EnsureLoaded(); - if (!_state.Worktrees.Any(w => w.Id == wt.Id)) - _state.Worktrees.Add(wt); + lock (_stateLock) + { + if (!_state.Worktrees.Any(w => w.Id == wt.Id)) + _state.Worktrees.Add(wt); + } } /// @@ -389,8 +441,11 @@ public void AddRemoteWorktree(WorktreeInfo wt) public void AddRemoteRepo(RepositoryInfo repo) { EnsureLoaded(); - if (!_state.Repositories.Any(r => r.Id == repo.Id)) - _state.Repositories.Add(repo); + lock (_stateLock) + { + if (!_state.Repositories.Any(r => r.Id == repo.Id)) + _state.Repositories.Add(repo); + } } /// @@ -399,7 +454,10 @@ public void AddRemoteRepo(RepositoryInfo repo) public void RemoveRemoteWorktree(string worktreeId) { EnsureLoaded(); - _state.Worktrees.RemoveAll(w => w.Id == worktreeId); + lock (_stateLock) + { + _state.Worktrees.RemoveAll(w => w.Id == worktreeId); + } } /// @@ -408,7 +466,10 @@ public void RemoveRemoteWorktree(string worktreeId) public void RemoveRemoteRepo(string repoId) { EnsureLoaded(); - _state.Repositories.RemoveAll(r => r.Id == repoId); + lock (_stateLock) + { + _state.Repositories.RemoveAll(r => r.Id == repoId); + } } /// @@ -425,11 +486,14 @@ public async Task RemoveRepositoryAsync(string repoId, bool deleteFromDisk, Canc var worktrees = _state.Worktrees.Where(w => w.RepoId == repoId).ToList(); foreach (var wt in worktrees) { - try { await RemoveWorktreeAsync(wt.Id, ct); } catch { } + try { await RemoveWorktreeAsync(wt.Id, ct: ct); } catch { } } - _state.Repositories.RemoveAll(r => r.Id == repoId); - _state.Worktrees.RemoveAll(w => w.RepoId == repoId); + lock (_stateLock) + { + _state.Repositories.RemoveAll(r => r.Id == repoId); + _state.Worktrees.RemoveAll(w => w.RepoId == repoId); + } Save(); if (deleteFromDisk && Directory.Exists(repo.BareClonePath)) @@ -468,7 +532,7 @@ public void LinkSessionToWorktree(string worktreeId, string sessionName) /// /// Fetch latest from remote for a repository. /// - public async Task FetchAsync(string repoId, CancellationToken ct = default) + public virtual async Task FetchAsync(string repoId, CancellationToken ct = default) { EnsureLoaded(); var repo = _state.Repositories.FirstOrDefault(r => r.Id == repoId) diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot/Services/WsBridgeServer.cs index 6165be980c..ff363fa99f 100644 --- a/PolyPilot/Services/WsBridgeServer.cs +++ b/PolyPilot/Services/WsBridgeServer.cs @@ -711,7 +711,7 @@ await SendToClientAsync(clientId, ws, if (wtReq.PrNumber.HasValue) wt = await _repoManager.CreateWorktreeFromPrAsync(wtReq.RepoId, wtReq.PrNumber.Value, ct); else - wt = await _repoManager.CreateWorktreeAsync(wtReq.RepoId, wtReq.BranchName ?? "main", null, ct); + wt = await _repoManager.CreateWorktreeAsync(wtReq.RepoId, wtReq.BranchName ?? "main", null, ct: ct); await SendToClientAsync(clientId, ws, BridgeMessage.Create(BridgeMessageTypes.WorktreeCreated, new WorktreeCreatedPayload