Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
be501d0
Streamline session+worktree creation: atomic API, quick-create, inlin…
PureWeen Feb 24, 2026
35811c6
Rename menu items: Quick Branch + Session / Named Branch + Session
PureWeen Feb 24, 2026
754682c
Make repo group menu button (…) always visible
PureWeen Feb 24, 2026
20add8a
Fix: resumed sessions show 'Sending' instead of correct processing phase
PureWeen Feb 24, 2026
170a2cc
Add 'Move to Worktree' option in session context menu
PureWeen Feb 24, 2026
b121516
Rework 'Move to Worktree' to create new worktree and preserve session
PureWeen Feb 24, 2026
4f2147a
Remove 'Move to Worktree' feature
PureWeen Feb 24, 2026
451f6e6
Add per-worker worktree isolation for multi-agent groups
PureWeen Feb 24, 2026
7782c12
Replace worktree picker with repo picker for multi-agent creation
PureWeen Feb 24, 2026
332e0fc
Add worktree strategy selector and fix multi-agent session creation
PureWeen Feb 24, 2026
496daed
Make worktree creation resilient — fall back to shared on failure
PureWeen Feb 25, 2026
3cd832b
Make worktree strategy selector more prominent in multi-agent creation
PureWeen Feb 25, 2026
49ebf98
Add 20 worktree strategy tests and diagnostic logging
PureWeen Feb 25, 2026
6e629f0
Fix: sanitize team name for git branch names (spaces broke worktree c…
PureWeen Feb 25, 2026
45de926
Harden worktree cleanup: handle invalid directories and missing repos
PureWeen Feb 25, 2026
ce54980
Hide 'Remove Repo' option from multi-agent team menus
PureWeen Feb 25, 2026
51b2b76
Fix: clean up partial directories when git worktree add fails
PureWeen Feb 25, 2026
ee4eb37
Clean up git branches when removing worktrees
PureWeen Feb 25, 2026
ecdb5e1
Track all created worktree IDs on group for reliable cleanup
PureWeen Feb 25, 2026
12a6821
Redesign New Session form: repo-first flow with mode chips and worktr…
PureWeen Feb 25, 2026
753c5dd
Unify session close into single menu item with delete worktree checkbox
PureWeen Feb 25, 2026
084db13
Empty mode sessions get no working directory (scratch sessions)
PureWeen Feb 25, 2026
e5f03a1
Empty sessions use fresh temp directory instead of ProjectDir
PureWeen Feb 25, 2026
913815c
Close session popup dialog with delete worktree/branch checkboxes
PureWeen Feb 25, 2026
39b0a10
Fix close dialog: move outside component tree to avoid overflow clipping
PureWeen Feb 25, 2026
51f9216
Fix 8 issues from multi-model review (Opus, Sonnet, Codex)
PureWeen Feb 25, 2026
b69b3f3
Fix test failures caused by our changes (CloseSessionIcon, InputSelec…
PureWeen Feb 25, 2026
8d9b8c2
Fix close dialog invisible: portal to document.body to escape overflo…
PureWeen Feb 25, 2026
39716bf
Show quick-create errors inline under repo group header
PureWeen Feb 25, 2026
dfad27d
Guard RepoManager.Save() against persisting empty state after failed …
PureWeen Feb 25, 2026
04e660e
Address PR #205 review findings from multi-model consensus
PureWeen Feb 26, 2026
3c4726f
Add tests for PR review findings: save guard, shared strategy, Worktr…
PureWeen Feb 26, 2026
454b611
Address round-2 review: portal cleanup, thread safety, shared strategy
PureWeen Feb 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions PolyPilot.Tests/CloseSessionIconTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
namespace PolyPilot.Tests;

/// <summary>
/// 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).
/// </summary>
public class CloseSessionIconTests
{
Expand All @@ -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]
Expand All @@ -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);
}
}
1 change: 0 additions & 1 deletion PolyPilot.Tests/InputSelectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ public void ValueBoundInputs_MustNotUse_OnKeyDown()
/// </summary>
[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)
Expand Down
131 changes: 131 additions & 0 deletions PolyPilot.Tests/RepoManagerTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using PolyPilot.Models;
using PolyPilot.Services;

namespace PolyPilot.Tests;
Expand Down Expand Up @@ -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<T>(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
}
Loading