Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
137 changes: 137 additions & 0 deletions PolyPilot.Tests/SessionOrganizationTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using PolyPilot.Models;
using PolyPilot.Services;

namespace PolyPilot.Tests;

Expand Down Expand Up @@ -116,3 +118,138 @@ public void OrganizationCommandPayload_Serializes()
Assert.Equal("test-session", deserialized.SessionName);
}
}

/// <summary>
/// Tests for CopilotService.MoveSession behaviour including the auto-create-meta fix.
/// </summary>
public class MoveSessionTests
{
private readonly StubChatDatabase _chatDb = new();
private readonly StubServerManager _serverManager = new();
private readonly StubWsBridgeClient _bridgeClient = new();
private readonly StubDemoService _demoService = new();
private readonly RepoManager _repoManager = new();
private readonly IServiceProvider _serviceProvider;

public MoveSessionTests()
{
var services = new ServiceCollection();
_serviceProvider = services.BuildServiceProvider();
}

private CopilotService CreateService() =>
new CopilotService(_chatDb, _serverManager, _bridgeClient, _repoManager, _serviceProvider, _demoService);

[Fact]
public void MoveSession_WithExistingMeta_UpdatesGroupId()
{
var svc = CreateService();

// Set up a group and a session meta
var group = svc.CreateGroup("Work");
svc.Organization.Sessions.Add(new SessionMeta
{
SessionName = "my-session",
GroupId = SessionGroup.DefaultId
});

svc.MoveSession("my-session", group.Id);

var meta = svc.Organization.Sessions.FirstOrDefault(m => m.SessionName == "my-session");
Assert.NotNull(meta);
Assert.Equal(group.Id, meta!.GroupId);
}

[Fact]
public void MoveSession_WithoutExistingMeta_CreatesMetaInTargetGroup()
{
var svc = CreateService();

// Create a group but do NOT add a SessionMeta for the session
var group = svc.CreateGroup("Work");

svc.MoveSession("orphan-session", group.Id);

var meta = svc.Organization.Sessions.FirstOrDefault(m => m.SessionName == "orphan-session");
Assert.NotNull(meta);
Assert.Equal(group.Id, meta!.GroupId);
}

[Fact]
public void MoveSession_ToNonExistentGroup_DoesNothing()
{
var svc = CreateService();

svc.Organization.Sessions.Add(new SessionMeta
{
SessionName = "my-session",
GroupId = SessionGroup.DefaultId
});

svc.MoveSession("my-session", "non-existent-group");

var meta = svc.Organization.Sessions.FirstOrDefault(m => m.SessionName == "my-session");
Assert.NotNull(meta);
Assert.Equal(SessionGroup.DefaultId, meta!.GroupId);
}

[Fact]
public void MoveSession_BetweenGroups_UpdatesCorrectly()
{
var svc = CreateService();

var groupA = svc.CreateGroup("Group A");
var groupB = svc.CreateGroup("Group B");
svc.Organization.Sessions.Add(new SessionMeta
{
SessionName = "my-session",
GroupId = groupA.Id
});

// Move from A to B
svc.MoveSession("my-session", groupB.Id);

var meta = svc.Organization.Sessions.FirstOrDefault(m => m.SessionName == "my-session");
Assert.NotNull(meta);
Assert.Equal(groupB.Id, meta!.GroupId);
}

[Fact]
public void MoveSession_BackToDefaultGroup_Works()
{
var svc = CreateService();

var group = svc.CreateGroup("Custom");
svc.Organization.Sessions.Add(new SessionMeta
{
SessionName = "my-session",
GroupId = group.Id
});

svc.MoveSession("my-session", SessionGroup.DefaultId);

var meta = svc.Organization.Sessions.FirstOrDefault(m => m.SessionName == "my-session");
Assert.NotNull(meta);
Assert.Equal(SessionGroup.DefaultId, meta!.GroupId);
}

[Fact]
public void MoveSession_FiresStateChanged()
{
var svc = CreateService();

var group = svc.CreateGroup("Work");
svc.Organization.Sessions.Add(new SessionMeta
{
SessionName = "my-session",
GroupId = SessionGroup.DefaultId
});

bool stateChanged = false;
svc.OnStateChanged += () => stateChanged = true;

svc.MoveSession("my-session", group.Id);

Assert.True(stateChanged);
}
}
15 changes: 15 additions & 0 deletions PolyPilot/Components/Layout/SessionListItem.razor
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,21 @@
📌 Pin
</button>
}
@if (Groups != null && Groups.Count > 1)
{
<div class="menu-separator"></div>
<div class="menu-submenu">
<span class="menu-item submenu-label">📁 Move to…</span>
@foreach (var g in Groups)
{
if (g.Id == Meta?.GroupId) continue;
var targetId = g.Id;
<button class="menu-item submenu-item" @onclick="() => { OnCloseMenu.InvokeAsync(); OnMove.InvokeAsync(targetId); }">
@g.Name
</button>
}
</div>
}
<div class="menu-separator"></div>
@if (!string.IsNullOrEmpty(sessionDir))
{
Expand Down
16 changes: 16 additions & 0 deletions PolyPilot/Components/Layout/SessionListItem.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,22 @@
.menu-item.destructive { color: var(--accent-primary); }
.menu-item.destructive:hover { background: var(--control-border); color: var(--accent-primary); }

.menu-submenu {
display: flex;
flex-direction: column;
}

.submenu-label {
cursor: default;
opacity: 0.7;
font-size: calc(var(--type-callout) - 1px);
}
.submenu-label:hover { background: transparent; color: var(--text-on-surface); }

.submenu-item {
padding-left: 1.2rem;
}

.rename-input {
width: 100%;
padding: 0.2rem 0.4rem;
Expand Down
16 changes: 13 additions & 3 deletions PolyPilot/Services/CopilotService.Organization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,23 @@ public void PinSession(string sessionName, bool pinned)

public void MoveSession(string sessionName, string groupId)
{
if (!Organization.Groups.Any(g => g.Id == groupId))
return;

var meta = Organization.Sessions.FirstOrDefault(m => m.SessionName == sessionName);
if (meta != null && Organization.Groups.Any(g => g.Id == groupId))
if (meta == null)
{
// Session exists but wasn't reconciled yet — create meta on the fly
meta = new SessionMeta { SessionName = sessionName, GroupId = groupId };
Organization.Sessions.Add(meta);
Comment on lines 173 to +178
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a potential race condition here. Organization.Sessions is a List<SessionMeta> which is not thread-safe. This method can be called from both the UI thread (via Blazor event handlers in SessionSidebar.razor) and background threads (via WsBridgeServer.HandleOrganizationCommand which runs in the WebSocket receive loop).

The Add operation on line 178 could collide with concurrent reads/writes from other threads. Consider adding synchronization (e.g., lock statement) around the list access, or using a thread-safe collection. This same issue exists in other organization methods that modify Organization.Sessions and Organization.Groups.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

}
else
{
meta.GroupId = groupId;
SaveOrganization();
OnStateChanged?.Invoke();
}

SaveOrganization();
OnStateChanged?.Invoke();
}

public SessionGroup CreateGroup(string name)
Expand Down