Skip to content

Commit 2f086a8

Browse files
PureWeenCopilot
andauthored
feat: Manual Focus ordering + Mac sleep/wake resilience (#415)
## Summary Reworks the PolyPilot dashboard Focus strip to give full manual control over session ordering, adds Mac sleep/wake resilience, and improves card scroll stability. ## Features ### 🎯 Manual Focus Ordering - `FocusOrder` list in `OrganizationState` — full manual control, no auto-sorting - `GetFocusSessions()` returns sessions in exact `FocusOrder` sequence; bootstraps from all non-worker sessions on first use (upgrade path) - **⬆ / ⬇** reorder buttons on each Focus strip card - **✕** remove button to evict a session from the Focus strip - `AddToFocus(sessionName)` called in `SendPromptAsync` so sending a message re-adds it automatically - `FocusOrder` persisted in `SaveOrganizationCore()` snapshot (was missing — data loss bug fixed) ### 🤖 Worker Filtering - Workers excluded from Focus strip by both `Role == Worker` and name-pattern regex (`-[Ww]orker-\d+`) - Orchestrators show an **active worker count badge** when workers are processing ### 💤 Mac Sleep/Wake Resilience - `MacSleepWakeMonitor`: subscribes to `NSWorkspaceDidWakeNotification` / `NSWorkspaceWillSleepNotification` via ObjC P/Invoke on `NSWorkspace.sharedWorkspace.notificationCenter` - Triggers `CheckConnectionHealthAsync` immediately on wake — fixes PolyPilot stalling after Mac sleep ### 🖱 Card Scroll Stability - `wasAtBottom` tracking in the textarea auto-resize script: if the card was scrolled to the bottom before a keypress, it stays pinned after the textarea grows — prevents jitter while typing during streaming ### 🔄 WsBridgeServer Stability - `AcceptLoopAsync` now uses exponential backoff on accept failures to prevent tight failure loops ## Bug Fixes (from code review) - **FocusOrder not persisted**: `SaveOrganizationCore()` now includes `FocusOrder = Organization.FocusOrder.ToList()` in the state snapshot - **Cached worker regex**: Extracted `private static readonly Regex WorkerNamePattern` — was re-compiled on every `GetFocusSessions` and `AddToFocus` call - **TurnEndFallbackTests flakiness**: Increased timing margins (50ms→200ms wait) so tests don't fail under parallel test load ## Technical Details - `MarkFocusHandled` kept as `=> DemoteFocusSession(sessionName)` for backward compatibility - `FocusOverride` enum and `SessionMeta.HandledAt` kept in model for JSON round-trip compatibility - `MacSleepWakeMonitor` uses ObjC P/Invoke because `NSWorkspace` has no direct .NET binding on Mac Catalyst; falls back to `NSNotificationCenter.DefaultCenter` if the ObjC call fails --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 58243f7 commit 2f086a8

15 files changed

Lines changed: 599 additions & 207 deletions

PolyPilot.Tests/DashboardFeatureTests.cs

Lines changed: 125 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -182,23 +182,27 @@ public void HealMultiAgentGroups_Phase4_PreservesRolesInActualMultiAgentGroup()
182182
#region GetFocusSessions logic
183183

184184
[Fact]
185-
public void GetFocusSessions_ReturnsProcessingSessions()
185+
public void GetFocusSessions_Bootstrap_IncludesAllNonWorkerSessions()
186186
{
187187
var svc = CreateService();
188188

189-
var processing = new AgentSessionInfo { Name = "active-session", Model = "m", IsProcessing = true };
189+
var active = new AgentSessionInfo { Name = "active-session", Model = "m", IsProcessing = true };
190190
var idle = new AgentSessionInfo { Name = "idle-session", Model = "m", IsProcessing = false };
191-
idle.LastUpdatedAt = DateTime.Now.AddDays(-2); // old, not in 24h window
192191

193-
InjectSession(svc, processing);
192+
InjectSession(svc, active);
194193
InjectSession(svc, idle);
195194
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "active-session", GroupId = SessionGroup.DefaultId });
196195
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "idle-session", GroupId = SessionGroup.DefaultId });
196+
// FocusOrder is empty — bootstraps from all sessions
197197

198198
var focus = svc.GetFocusSessions();
199199

200+
// Both sessions appear (no filtering by processing status)
200201
Assert.Contains(focus, s => s.Name == "active-session");
201-
Assert.DoesNotContain(focus, s => s.Name == "idle-session");
202+
Assert.Contains(focus, s => s.Name == "idle-session");
203+
// FocusOrder was populated
204+
Assert.Contains(svc.Organization.FocusOrder, n => n == "active-session");
205+
Assert.Contains(svc.Organization.FocusOrder, n => n == "idle-session");
202206
}
203207

204208
[Fact]
@@ -207,182 +211,199 @@ public void GetFocusSessions_ReturnsSessionsWithUnreadMessages()
207211
var svc = CreateService();
208212

209213
var unread = new AgentSessionInfo { Name = "unread-session", Model = "m", IsProcessing = false };
210-
unread.LastUpdatedAt = DateTime.Now.AddDays(-2); // old
211214
unread.History.Add(ChatMessage.AssistantMessage("Response pending your attention"));
212215

213-
var idle = new AgentSessionInfo { Name = "idle-session", Model = "m", IsProcessing = false };
214-
idle.LastUpdatedAt = DateTime.Now.AddDays(-2);
215-
216216
InjectSession(svc, unread);
217-
InjectSession(svc, idle);
218217
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "unread-session", GroupId = SessionGroup.DefaultId });
219-
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "idle-session", GroupId = SessionGroup.DefaultId });
220218

221219
var focus = svc.GetFocusSessions();
222220

223221
Assert.Contains(focus, s => s.Name == "unread-session");
224-
Assert.DoesNotContain(focus, s => s.Name == "idle-session");
225222
}
226223

227224
[Fact]
228-
public void GetFocusSessions_FocusOverrideIncluded_AlwaysShows()
225+
public void AddToFocus_AddsSessionToBottom()
229226
{
230227
var svc = CreateService();
228+
svc.Organization.FocusOrder.AddRange(["first", "second"]);
229+
var session = new AgentSessionInfo { Name = "new-session", Model = "m" };
230+
InjectSession(svc, session);
231+
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "new-session", GroupId = SessionGroup.DefaultId });
231232

232-
var session = new AgentSessionInfo { Name = "pinned-session", Model = "m", IsProcessing = false };
233-
session.LastUpdatedAt = DateTime.Now.AddDays(-10); // very old, would not appear normally
233+
svc.AddToFocus("new-session");
234234

235-
InjectSession(svc, session);
236-
svc.Organization.Sessions.Add(new SessionMeta
237-
{
238-
SessionName = "pinned-session",
239-
GroupId = SessionGroup.DefaultId,
240-
FocusOverride = FocusOverride.Included
241-
});
235+
Assert.Equal(["first", "second", "new-session"], svc.Organization.FocusOrder);
236+
}
242237

243-
var focus = svc.GetFocusSessions();
238+
[Fact]
239+
public void AddToFocus_AlreadyPresent_IsIdempotent()
240+
{
241+
var svc = CreateService();
242+
svc.Organization.FocusOrder.AddRange(["first", "second"]);
243+
244+
svc.AddToFocus("first");
244245

245-
Assert.Contains(focus, s => s.Name == "pinned-session");
246+
Assert.Equal(["first", "second"], svc.Organization.FocusOrder);
246247
}
247248

248249
[Fact]
249-
public void GetFocusSessions_FocusOverrideExcluded_NeverShows()
250+
public void RemoveFromFocus_RemovesSession()
250251
{
251252
var svc = CreateService();
253+
svc.Organization.FocusOrder.AddRange(["first", "second", "third"]);
252254

253-
// This session IS processing, but is explicitly excluded from Focus
254-
var session = new AgentSessionInfo { Name = "excluded-session", Model = "m", IsProcessing = true };
255-
InjectSession(svc, session);
256-
svc.Organization.Sessions.Add(new SessionMeta
257-
{
258-
SessionName = "excluded-session",
259-
GroupId = SessionGroup.DefaultId,
260-
FocusOverride = FocusOverride.Excluded
261-
});
255+
svc.RemoveFromFocus("second");
262256

263-
var focus = svc.GetFocusSessions();
257+
Assert.Equal(["first", "third"], svc.Organization.FocusOrder);
258+
}
259+
260+
[Fact]
261+
public void RemoveFromFocus_NonExistent_DoesNotThrow()
262+
{
263+
var svc = CreateService();
264+
svc.Organization.FocusOrder.Add("only");
265+
266+
svc.RemoveFromFocus("ghost");
264267

265-
Assert.DoesNotContain(focus, s => s.Name == "excluded-session");
268+
Assert.Equal(["only"], svc.Organization.FocusOrder);
266269
}
267270

268271
[Fact]
269-
public void GetFocusSessions_WorkersInMultiAgentGroup_ExcludedFromFocus()
272+
public void PromoteFocusSession_MovesUp()
270273
{
271274
var svc = CreateService();
275+
svc.Organization.FocusOrder.AddRange(["a", "b", "c"]);
272276

273-
var maGroup = new SessionGroup { Id = "ma-group", Name = "MyTeam", IsMultiAgent = true };
274-
svc.Organization.Groups.Add(maGroup);
277+
svc.PromoteFocusSession("b");
275278

276-
var worker = new AgentSessionInfo { Name = "MyTeam-worker-1", Model = "m", IsProcessing = true };
277-
var orch = new AgentSessionInfo { Name = "MyTeam-orchestrator", Model = "m", IsProcessing = true };
278-
InjectSession(svc, worker);
279-
InjectSession(svc, orch);
279+
Assert.Equal(["b", "a", "c"], svc.Organization.FocusOrder);
280+
}
280281

281-
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "MyTeam-worker-1", GroupId = "ma-group", Role = MultiAgentRole.Worker });
282-
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "MyTeam-orchestrator", GroupId = "ma-group", Role = MultiAgentRole.Orchestrator });
282+
[Fact]
283+
public void PromoteFocusSession_AlreadyFirst_NoChange()
284+
{
285+
var svc = CreateService();
286+
svc.Organization.FocusOrder.AddRange(["a", "b", "c"]);
283287

284-
var focus = svc.GetFocusSessions();
288+
svc.PromoteFocusSession("a");
285289

286-
// Workers should not appear in Focus (they're managed by orchestrator)
287-
Assert.DoesNotContain(focus, s => s.Name == "MyTeam-worker-1");
288-
// Orchestrator (Role != Worker) should appear since it's processing
289-
Assert.Contains(focus, s => s.Name == "MyTeam-orchestrator");
290+
Assert.Equal(["a", "b", "c"], svc.Organization.FocusOrder);
290291
}
291292

292293
[Fact]
293-
public void GetFocusSessions_ProcessingFirstInSort()
294+
public void DemoteFocusSession_MovesDown()
294295
{
295296
var svc = CreateService();
297+
svc.Organization.FocusOrder.AddRange(["a", "b", "c"]);
296298

297-
var processing = new AgentSessionInfo { Name = "processing", Model = "m", IsProcessing = true };
298-
var unread = new AgentSessionInfo { Name = "unread", Model = "m", IsProcessing = false };
299-
unread.History.Add(ChatMessage.AssistantMessage("Pending attention"));
300-
unread.LastUpdatedAt = DateTime.Now.AddMinutes(-5);
299+
svc.DemoteFocusSession("b");
301300

302-
InjectSession(svc, processing);
303-
InjectSession(svc, unread);
304-
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "processing", GroupId = SessionGroup.DefaultId });
305-
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "unread", GroupId = SessionGroup.DefaultId });
301+
Assert.Equal(["a", "c", "b"], svc.Organization.FocusOrder);
302+
}
303+
304+
[Fact]
305+
public void DemoteFocusSession_AlreadyLast_NoChange()
306+
{
307+
var svc = CreateService();
308+
svc.Organization.FocusOrder.AddRange(["a", "b", "c"]);
309+
310+
svc.DemoteFocusSession("c");
311+
312+
Assert.Equal(["a", "b", "c"], svc.Organization.FocusOrder);
313+
}
314+
315+
[Fact]
316+
public void GetFocusSessions_RespectsManualFocusOrder()
317+
{
318+
var svc = CreateService();
319+
320+
var a = new AgentSessionInfo { Name = "a", Model = "m", IsProcessing = true };
321+
var b = new AgentSessionInfo { Name = "b", Model = "m", IsProcessing = false };
322+
InjectSession(svc, a);
323+
InjectSession(svc, b);
324+
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "a", GroupId = SessionGroup.DefaultId });
325+
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "b", GroupId = SessionGroup.DefaultId });
326+
// Manually put b before a — no auto-sort should override this
327+
svc.Organization.FocusOrder.AddRange(["b", "a"]);
306328

307329
var focus = svc.GetFocusSessions();
308330
var names = focus.Select(s => s.Name).ToList();
309331

310-
// Processing comes before unread
311-
Assert.True(names.IndexOf("processing") < names.IndexOf("unread"),
312-
"Processing session should sort before unread session");
332+
Assert.Equal(["b", "a"], names);
313333
}
314334

315335
[Fact]
316-
public void GetFocusSessions_HandledSessions_SortToBottom()
336+
public void GetFocusSessions_WorkersInMultiAgentGroup_ExcludedFromFocus()
317337
{
318338
var svc = CreateService();
319339

320-
var unhandled = new AgentSessionInfo { Name = "unhandled", Model = "m", IsProcessing = false };
321-
unhandled.LastUpdatedAt = DateTime.Now.AddMinutes(-30);
322-
unhandled.History.Add(ChatMessage.AssistantMessage("Needs attention"));
340+
var maGroup = new SessionGroup { Id = "ma-group", Name = "MyTeam", IsMultiAgent = true };
341+
svc.Organization.Groups.Add(maGroup);
323342

324-
var handled = new AgentSessionInfo { Name = "handled", Model = "m", IsProcessing = false };
325-
handled.LastUpdatedAt = DateTime.Now.AddMinutes(-5); // More recent but handled
343+
var worker = new AgentSessionInfo { Name = "MyTeam-worker-1", Model = "m", IsProcessing = true };
344+
var orch = new AgentSessionInfo { Name = "MyTeam-orchestrator", Model = "m", IsProcessing = true };
345+
InjectSession(svc, worker);
346+
InjectSession(svc, orch);
326347

327-
InjectSession(svc, unhandled);
328-
InjectSession(svc, handled);
329-
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "unhandled", GroupId = SessionGroup.DefaultId });
330-
svc.Organization.Sessions.Add(new SessionMeta
331-
{
332-
SessionName = "handled",
333-
GroupId = SessionGroup.DefaultId,
334-
HandledAt = DateTime.Now.AddMinutes(-10)
335-
});
348+
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "MyTeam-worker-1", GroupId = "ma-group", Role = MultiAgentRole.Worker });
349+
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "MyTeam-orchestrator", GroupId = "ma-group", Role = MultiAgentRole.Orchestrator });
350+
// Workers must not bootstrap into FocusOrder
351+
// Orchestrator should be in FocusOrder (bootstrap adds non-workers)
336352

337353
var focus = svc.GetFocusSessions();
338-
var names = focus.Select(s => s.Name).ToList();
339354

340-
// Unhandled (even with older activity) sorts before handled
341-
Assert.True(names.IndexOf("unhandled") < names.IndexOf("handled"),
342-
"Unhandled sessions should sort before handled sessions");
355+
// Workers should not appear in Focus (they're managed by orchestrator)
356+
Assert.DoesNotContain(focus, s => s.Name == "MyTeam-worker-1");
357+
// Orchestrator (Role != Worker) should appear
358+
Assert.Contains(focus, s => s.Name == "MyTeam-orchestrator");
343359
}
344360

345361
[Fact]
346-
public void GetFocusSessions_OldestWaitingFirst_WithinUnhandled()
362+
public void GetFocusSessions_WorkerByNamePattern_ExcludedFromFocusEvenIfRoleNone()
347363
{
348364
var svc = CreateService();
365+
// Worker session with Role=None (corruption — should still be excluded by name pattern)
366+
var worker = new AgentSessionInfo { Name = "AGR-worker-1", Model = "m", IsProcessing = true };
367+
InjectSession(svc, worker);
368+
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "AGR-worker-1", GroupId = SessionGroup.DefaultId, Role = MultiAgentRole.None });
349369

350-
// "newer" session updated 5 min ago (less urgent — user saw it more recently)
351-
var newer = new AgentSessionInfo { Name = "newer", Model = "m", IsProcessing = false };
352-
newer.LastUpdatedAt = DateTime.Now.AddMinutes(-5);
353-
newer.History.Add(ChatMessage.AssistantMessage("Recent message"));
370+
var focus = svc.GetFocusSessions();
354371

355-
// "older" session waiting 60 min (more urgent — waiting longest)
356-
var older = new AgentSessionInfo { Name = "older", Model = "m", IsProcessing = false };
357-
older.LastUpdatedAt = DateTime.Now.AddMinutes(-60);
358-
older.History.Add(ChatMessage.AssistantMessage("Waiting a long time"));
372+
Assert.DoesNotContain(focus, s => s.Name == "AGR-worker-1");
373+
Assert.DoesNotContain(svc.Organization.FocusOrder, n => n == "AGR-worker-1");
374+
}
359375

360-
InjectSession(svc, newer);
361-
InjectSession(svc, older);
362-
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "newer", GroupId = SessionGroup.DefaultId });
363-
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "older", GroupId = SessionGroup.DefaultId });
376+
[Fact]
377+
public void GetFocusSessions_SessionsRemovedFromFocusOrder_NotReturned()
378+
{
379+
var svc = CreateService();
364380

381+
var a = new AgentSessionInfo { Name = "a", Model = "m" };
382+
var b = new AgentSessionInfo { Name = "b", Model = "m" };
383+
InjectSession(svc, a);
384+
InjectSession(svc, b);
385+
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "a", GroupId = SessionGroup.DefaultId });
386+
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "b", GroupId = SessionGroup.DefaultId });
387+
svc.Organization.FocusOrder.AddRange(["a", "b"]);
388+
389+
svc.RemoveFromFocus("b");
365390
var focus = svc.GetFocusSessions();
366-
var names = focus.Select(s => s.Name).ToList();
367391

368-
// Oldest waiting session sorts first (triage order)
369-
Assert.True(names.IndexOf("older") < names.IndexOf("newer"),
370-
"Oldest waiting session should sort before more recently active sessions");
392+
Assert.Contains(focus, s => s.Name == "a");
393+
Assert.DoesNotContain(focus, s => s.Name == "b");
371394
}
372395

373396
[Fact]
374-
public void MarkFocusHandled_SetsHandledAtOnMeta()
397+
public void MarkFocusHandled_DemotesSession()
375398
{
376399
var svc = CreateService();
377400
svc.Organization.Sessions.Add(new SessionMeta { SessionName = "my-session", GroupId = SessionGroup.DefaultId });
401+
svc.Organization.FocusOrder.AddRange(["my-session", "other"]);
378402

379-
var before = DateTime.Now;
380403
svc.MarkFocusHandled("my-session");
381-
var after = DateTime.Now;
382404

383-
var meta = svc.Organization.Sessions.First(m => m.SessionName == "my-session");
384-
Assert.NotNull(meta.HandledAt);
385-
Assert.InRange(meta.HandledAt!.Value, before, after);
405+
// MarkFocusHandled demotes (moves to bottom), does NOT remove
406+
Assert.Equal(["other", "my-session"], svc.Organization.FocusOrder);
386407
}
387408

388409
[Fact]

PolyPilot.Tests/StateChangeCoalescerTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,12 @@ public async Task SeparateBursts_FireSeparately()
7070
svc.NotifyStateChangedCoalesced();
7171
// Wait well beyond the coalesce window (150ms) to ensure the timer fires,
7272
// even under heavy CI/GC load. Previous 300ms was flaky under load.
73-
await Task.Delay(500);
73+
await Task.Delay(800);
7474

7575
// Second burst after timer has fired
7676
for (int i = 0; i < 10; i++)
7777
svc.NotifyStateChangedCoalesced();
78-
await Task.Delay(500);
78+
await Task.Delay(800);
7979

8080
// Each burst should produce ~1 notification
8181
Assert.InRange(fireCount, 2, 4);

PolyPilot.Tests/TurnEndFallbackTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ public async Task FallbackTimer_NotCancelled_FiresAfterDelay()
8282
catch (OperationCanceledException) { }
8383
});
8484

85-
await Task.Delay(200);
85+
await Task.Delay(500);
8686
Assert.True(fired, "Fallback timer should fire when CTS is not cancelled");
8787
}
8888

@@ -209,7 +209,7 @@ public async Task ToolFallback_NoTurnStart_EventuallyFires()
209209
catch (OperationCanceledException) { }
210210
});
211211

212-
await Task.Delay(400);
212+
await Task.Delay(800);
213213
Assert.True(completeResponseFired, "Fallback must fire when no TurnStart or SessionIdle arrives");
214214
}
215215
}

PolyPilot/App.xaml.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ namespace PolyPilot;
55
public partial class App : Application
66
{
77
private readonly CopilotService _copilotService;
8+
private readonly WsBridgeServer _bridgeServer;
89

9-
public App(INotificationManagerService notificationService, CopilotService copilotService)
10+
public App(INotificationManagerService notificationService, CopilotService copilotService, WsBridgeServer bridgeServer)
1011
{
1112
_copilotService = copilotService;
13+
_bridgeServer = bridgeServer;
1214
InitializeComponent();
1315
_ = notificationService.InitializeAsync();
1416

@@ -39,6 +41,15 @@ protected override Window CreateWindow(IActivationState? activationState)
3941
window.Width = 1400;
4042
window.Height = 900;
4143
}
44+
45+
#if MACCATALYST
46+
// Subscribe to NSWorkspace sleep/wake and screen-lock/unlock notifications so we
47+
// can proactively recover the copilot connection and re-sync mobile clients.
48+
// App.OnResume() only fires when the app is re-activated by the user, not on
49+
// system wake or lock-screen unlock without clicking PolyPilot.
50+
PolyPilot.Platforms.MacCatalyst.MacSleepWakeMonitor.Register(_copilotService, _bridgeServer);
51+
#endif
52+
4253
return window;
4354
}
4455

0 commit comments

Comments
 (0)