Skip to content

Dashboard session management: Focus strip, enriched cards, and grid controls#413

Merged
PureWeen merged 33 commits intomainfrom
dashboard-session-management
Mar 21, 2026
Merged

Dashboard session management: Focus strip, enriched cards, and grid controls#413
PureWeen merged 33 commits intomainfrom
dashboard-session-management

Conversation

@PureWeen
Copy link
Copy Markdown
Owner

Summary

Reworks the PolyPilot dashboard for managing 50+ sessions efficiently — the goal is to spend 95% of time in the dashboard view without needing to expand individual sessions.

Features

🎯 Focus Strip

  • Auto-detected sticky section at the top of the dashboard showing sessions that are currently processing or have unread messages
  • Sessions from multi-agent worker roles are excluded automatically
  • Manual override: dismiss (✕) a session from Focus, or add any session via context menu
  • FocusOverride enum on SessionMeta (Auto/Included/Excluded), persisted in organization.json

📋 Enriched Session Cards

  • Message previews on cards — see last 10-15 messages without expanding
  • Processing status with elapsed time and tool call count
  • Intent display — current assistant intent shown on active cards
  • Reflection cycle pills — progress bar showing iteration N/M with stop button
  • Stuck session banners — visible warning when ConsecutiveStuckCount >= 1
  • Last prompt preview for idle sessions with no loaded messages

📐 Grid Layout Controls

  • Column count +/− buttons (2-6 columns) in the dashboard toolbar
  • Card min-height +/− buttons (150-600px, step 50) in the dashboard toolbar
  • Both settings persisted to ui-state.json across restarts

🔍 Status Filter Bar

  • Filter chips in the sidebar: All / Processing / Needs Attention / Stuck / Idle
  • Session counts shown per filter

📍 Scroll Position Preservation

  • Dashboard-level scroll position preserved across Blazor re-renders
  • Individual .card-messages scroll positions preserved per session key
  • Cards only auto-scroll to bottom when a new message is sent (_needsScrollToBottom)

Bug Fixes

  • MultiAgentRole.None added as default to prevent mass mislabeling of regular sessions as Workers
  • Phase 4 healer in HealMultiAgentGroups clears stale Worker/Orchestrator roles from non-multi-agent groups
  • LastUpdatedAt persistence — activity timestamps tracked across app restarts via active-sessions.json
  • Fixed external sessions appearing duplicated
  • Fixed card scroll-to-top on every re-render

Files Changed

  • Dashboard.razor / Dashboard.razor.css — Focus strip, grid controls, scroll preservation
  • SessionCard.razor / SessionCard.razor.css — enriched card content
  • SessionSidebar.razor / SessionSidebar.razor.css — status filter bar
  • SessionOrganization.csFocusOverride enum on SessionMeta
  • CopilotService.Organization.csGetFocusSessions(), SetFocusOverride()
  • CopilotService.csGridColumns, CardMinHeight on UiState; MultiAgentRole.None
  • CopilotService.Persistence.csSaveUiState extended for grid/height settings
  • index.htmlsaveCardScrollPositions() JS helper

@PureWeen
Copy link
Copy Markdown
Owner Author

PR #413 Review: Dashboard session management

CI: ⚠️ No CI checks configured on this branch
Tests: 1 failure — WsBridgeIntegrationTests.Organization_BroadcastsStateBack_ToClient (pre-existing flaky test, not PR-specific)
Prior reviews: None (first review)


🔴 Consensus Findings

F1 🟡 MODERATE — Dashboard.razor:969 — Locale-sensitive double in JS eval (5/5 consensus)

_pendingScrollTop (a double) is interpolated directly into a JS eval string:

_ = JS.InvokeVoidAsync("eval", $"var d = document.querySelector('.dashboard'); if (d) d.scrollTop = {scrollTop};");

On German/French/etc. locales, 1234.5 formats as "1234,5". JavaScript interprets d.scrollTop = 1234,5; using the comma operator → evaluates to 5. Scroll silently jumps to the wrong position.

Fix: scrollTop.ToString(System.Globalization.CultureInfo.InvariantCulture) in the interpolation, or pass the value as a JS interop parameter instead of eval.


F2 🟡 MODERATE — SessionSidebar.razor:~1260 — Filter bar hides but _statusFilter is never reset (5/5 consensus)

The filter bar auto-hides when processingCount == 0 && attentionCount == 0 && stuckCount == 0. But _statusFilter is not reset to All, so ApplyStatusFilter continues filtering the session list to zero results. The "All" chip that would reset it is hidden. Users see an empty sidebar with no visible recovery path — requiring a page navigation to restore.

Fix: Reset _statusFilter = SessionStatusFilter.All; before the early return in RenderFilterBar, or auto-reset it in the next RefreshState when counts drop to zero.


F3 🟡 MODERATE — CopilotService.Organization.cs:668GetFocusSessions() reads Organization.Sessions/Groups without _organizationLock (4/5 consensus)

GetFocusSessions() calls Organization.Sessions.ToDictionary() and Organization.Groups.ToDictionary() without holding _organizationLock. Per the existing invariant comment (line 191-192): _"ALL reads and writes to these lists MUST hold organizationLock when accessed from background threads." HealMultiAgentGroups() (which Phase 4 now also runs under lock) can mutate these lists on a background thread during app restore while the dashboard is rendering and calling GetFocusSessions(), risking InvalidOperationException. Same applies to SetFocusOverride() at line 650.

Fix: Use the existing snapshot helpers — SnapshotSessionMetas() / SnapshotGroups() — or take a brief lock (_organizationLock) to copy the lists before the LINQ. Both existing callers (GetOrganizedSessions, ReconcileOrganization) already follow this pattern.


F4 🟡 MODERATE — Dashboard.razor:273+411 — Focus strip and main grid share cardMenuSession/cardRenamingSession (4/5 consensus)

When a session appears in both the Focus strip and the main grid (the intended behavior), IsMenuOpen="@(cardMenuSession == session.Name)" and IsRenaming="@(cardRenamingSession == session.Name)" are simultaneously true on both cards. Opening the context menu on the focus card also opens it on the main grid card. Starting a rename on one makes the rename input appear on both.

Fix: Scope the key by strip: e.g. store "focus:" + session.Name vs "grid:" + session.Name in cardMenuSession, or add an IsInFocusStrip prefix to the equality check.


🟢 Minor (below consensus threshold — noted for awareness)

  • Dashboard.razor:261MessageCount="15" hardcoded on Focus strip cards; OnLoadMore increments cardMessageCounts (read by main grid) but not the focus strip card. Load More silently misfires. (3/5)
  • CopilotService.cs:4226SetActiveSession sets LastUpdatedAt = DateTime.Now on every click, keeping sessions in Focus for 24h after merely viewing them. May or may not be intentional design. (3/5)

Test Coverage

The PR correctly updated all test assertions for the MultiAgentRole.None default across 3 test files (latest commit 6870ef40 fixed the missed MuteWorkerNotificationsTests case). The Phase 4 healer has no dedicated tests — a test for "session moved from multi-agent group back to regular group gets its role cleared" would be valuable.


Summary

Finding Severity Consensus
F1 Locale bug in scroll eval 🟡 MODERATE 5/5
F2 Filter stuck/hidden 🟡 MODERATE 5/5
F3 GetFocusSessions/SetFocusOverride lock missing 🟡 MODERATE 4/5
F4 Shared menu state across focus+grid 🟡 MODERATE 4/5

Recommended action: ⚠️ Request changes

F1 and F2 are straightforward one-liners. F3 requires using the snapshot helpers already in the codebase. F4 needs a scoped key for the shared state. All four are fixable without structural changes.

PureWeen added a commit that referenced this pull request Mar 21, 2026
F1: Fix locale-sensitive double in JS eval for scroll restoration
- Use CultureInfo.InvariantCulture when interpolating _pendingScrollTop
  into eval string to prevent '1234,5' on German/French locales

F2: Auto-reset status filter when filter bar hides
- Reset _statusFilter to All when processingCount/attentionCount/stuckCount
  all drop to zero, preventing empty sidebar with no visible recovery path

F3: Fix GetFocusSessions/SetFocusOverride missing _organizationLock
- Use SnapshotSessionMetas()/SnapshotGroups() in GetFocusSessions() for
  thread-safe reads; take lock in SetFocusOverride before FirstOrDefault

F4: Scope cardMenuSession/cardRenamingSession by focus strip prefix
- Use 'focus:' + session.Name as key for focus strip cards to prevent
  opening the context menu on both focus and grid cards simultaneously
- CommitCardRename strips the prefix before passing to RenameSession

Minor: Replace hardcoded MessageCount=15 on focus strip cards with
cardMessageCounts dict (same as main grid), so LoadMore works correctly

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PureWeen added a commit that referenced this pull request Mar 21, 2026
22 new tests covering:

Phase 4 healer (3 tests):
- HealMultiAgentGroups_Phase4_ClearsWorkerRoleFromNonMultiAgentGroup
- HealMultiAgentGroups_Phase4_ClearsOrchestratorRoleFromDeletedGroup
- HealMultiAgentGroups_Phase2_PromotesGroupWithPersistedOrchestratorRole
- HealMultiAgentGroups_Phase4_PreservesRolesInActualMultiAgentGroup

GetFocusSessions logic (6 tests):
- Returns processing sessions
- Returns sessions with unread messages
- FocusOverride.Included always shows
- FocusOverride.Excluded never shows (even if processing)
- Workers in real multi-agent groups excluded
- Processing sessions sort before unread in Focus strip

TolerantEnumConverter (9 tests):
- Unknown string values fall back to default
- Known string values (Worker, Orchestrator) deserialize correctly
- Missing fields fall back to default
- Case-insensitive matching
- Serializes as string not integer
- FocusOverride unknown value falls back to Auto
- MultiAgentMode unknown value falls back to default

UiState persistence (4 tests):
- GridColumns default is 3
- CardMinHeight default is 250
- GridColumns and CardMinHeight round-trip serialization
- Legacy JSON without new fields uses defaults

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PureWeen added a commit that referenced this pull request Mar 21, 2026
F1: Fix locale-sensitive double in JS eval for scroll restoration
- Use CultureInfo.InvariantCulture when interpolating _pendingScrollTop
  into eval string to prevent '1234,5' on German/French locales

F2: Auto-reset status filter when filter bar hides
- Reset _statusFilter to All when processingCount/attentionCount/stuckCount
  all drop to zero, preventing empty sidebar with no visible recovery path

F3: Fix GetFocusSessions/SetFocusOverride missing _organizationLock
- Use SnapshotSessionMetas()/SnapshotGroups() in GetFocusSessions() for
  thread-safe reads; take lock in SetFocusOverride before FirstOrDefault

F4: Scope cardMenuSession/cardRenamingSession by focus strip prefix
- Use 'focus:' + session.Name as key for focus strip cards to prevent
  opening the context menu on both focus and grid cards simultaneously
- CommitCardRename strips the prefix before passing to RenameSession

Minor: Replace hardcoded MessageCount=15 on focus strip cards with
cardMessageCounts dict (same as main grid), so LoadMore works correctly

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PureWeen added a commit that referenced this pull request Mar 21, 2026
22 new tests covering:

Phase 4 healer (3 tests):
- HealMultiAgentGroups_Phase4_ClearsWorkerRoleFromNonMultiAgentGroup
- HealMultiAgentGroups_Phase4_ClearsOrchestratorRoleFromDeletedGroup
- HealMultiAgentGroups_Phase2_PromotesGroupWithPersistedOrchestratorRole
- HealMultiAgentGroups_Phase4_PreservesRolesInActualMultiAgentGroup

GetFocusSessions logic (6 tests):
- Returns processing sessions
- Returns sessions with unread messages
- FocusOverride.Included always shows
- FocusOverride.Excluded never shows (even if processing)
- Workers in real multi-agent groups excluded
- Processing sessions sort before unread in Focus strip

TolerantEnumConverter (9 tests):
- Unknown string values fall back to default
- Known string values (Worker, Orchestrator) deserialize correctly
- Missing fields fall back to default
- Case-insensitive matching
- Serializes as string not integer
- FocusOverride unknown value falls back to Auto
- MultiAgentMode unknown value falls back to default

UiState persistence (4 tests):
- GridColumns default is 3
- CardMinHeight default is 250
- GridColumns and CardMinHeight round-trip serialization
- Legacy JSON without new fields uses defaults

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen PureWeen force-pushed the dashboard-session-management branch from 232eb1f to e63c50e Compare March 21, 2026 12:28
@PureWeen
Copy link
Copy Markdown
Owner Author

🔄 Re-Review Round 2 — PR #413

Previous Findings Status

  • F1 🟡 Locale bug (scrollTop in JS eval): FIXED ✅ — ToString(CultureInfo.InvariantCulture) at Dashboard.razor:972
  • F2 🟡 _statusFilter not reset on filter bar hide: FIXED ✅ — resets to All in early-return block at SessionSidebar.razor:1253
  • F3 🟡 GetFocusSessions/SetFocusOverride lockless reads: FIXED ✅ — SnapshotSessionMetas()/SnapshotGroups() (both take lock internally); SetFocusOverride now wraps FirstOrDefault in lock (_organizationLock)
  • F4 🟡 Shared cardMenuSession/cardRenamingSession across focus strip + grid: FIXED ✅ — focusKey = "focus:" + session.Name scopes state; CommitCardRename strips prefix via key["focus:".Length..]

All four Round 1 findings are correctly addressed. The fixes are clean and follow established codebase patterns.


New Issues (consensus: 2+ of 5 models)

🟡 N1 (4/5 consensus) — Sort order comment vs. behavior mismatch
CopilotService.Organization.cs:709

In GetFocusSessions(), the ThenBy secondary sort for the Processing tier (Tier 0) uses ascending LastUpdatedAt, but the inline comment reads // Processing: most recent first. Ascending order puts the oldest processing session first — opposite of the stated intent. The Tier 1 and Tier 2 ascending behavior is intentional and correct. Only the Processing tier comment/behavior is misaligned. Fix: either change the comment to // oldest first (longest-running at top) if that's the intent, or use DateTime.MaxValue - s.LastUpdatedAt to invert within the ascending lambda for true most-recent-first.

🟡 N2 (2/5 consensus) — IsWorkerForTeamPrefix suffix match false positive
CopilotService.Organization.cs:1016

Strategy 2 fallback in IsWorkerForTeamPrefix uses orchTeamPrefix.EndsWith(workerPrefix, OrdinalIgnoreCase). This produces false positives when one team name is a proper suffix of another — e.g., an orchestrator named "Review Squad" will claim workers named "Squad-worker-1" (whose extracted workerPrefix is "Squad") because "Review Squad".EndsWith("Squad") is true and the length guard passes. In a multi-team worktree where both a "Squad" team and a "Review Squad" team exist simultaneously, Phase 4 healing could incorrectly assign roles across teams. The fix would add a word-boundary check: orchTeamPrefix.EndsWith(" " + workerPrefix, OrdinalIgnoreCase) to require a space separator.

🟢 N3 (2/5 consensus) — Permanent Focus exclusion no longer reachable from UI
SessionCard.razor:83, Dashboard.razor:~288

OnHandledInFocus (temporary — cleared by any new AI response) replaced OnDismissFromFocus (permanent FocusOverride.Excluded). The OnDismissFromFocus parameter still exists in SessionCard.razor (marked "kept for backward compatibility") and the FocusOverride.Excluded display path at line 58 remains, but no UI button calls it anymore from the focus strip. Sessions in Focus can now only be temporarily managed — they will re-enter on each new response indefinitely. This is likely an intentional UX shift (triage vs. dismiss) but removes a capability that existed in Round 1. Low risk; noting for design awareness.


New Commits — No Additional Issues

  • TolerantEnumConverter<T>: JsonTokenType.Null correctly falls through to reader.Skip(); return default. Enum.TryParse handles edge cases safely. ✅
  • MarkFocusHandled: Correctly acquires _organizationLock before FirstOrDefault. HandledAt cleared in CompleteResponse on UI thread (no lock needed — pre-existing codebase pattern). ✅
  • WsBridge recovery (TryRestartListenerAsync, keepalive sleep detection, App.OnResume health check): CheckConnectionHealthAsync is null-guarded (_client == null → return) and demo/remote mode guarded. Safe to call from OnResume. ✅
  • PP- prefix healer: IsWorkerForTeamPrefix Strategy 1 (exact prefix) handles the common case correctly. Strategy 2 (suffix fallback) is the concern flagged in N2 above.

Tests

2852 passed, 0 failed (full suite, including all 54 WsBridgeIntegrationTests and 22 new DashboardFeatureTests)

Verdict

⚠️ Request changes — two actionable items:

  1. N1 (comment or sort fix): Clarify or correct the Processing tier ThenBy sort direction. If "oldest first" is intentional for longest-running visibility, update the comment. If "newest first" was the intent, invert the sort.
  2. N2 (suffix guard): Add a word-boundary check to IsWorkerForTeamPrefix Strategy 2 to prevent cross-team false positives in multi-team worktrees.

N3 is a design note, not a blocker.

PureWeen and others added 23 commits March 21, 2026 10:41
…gement

- Status filter chips (All/Processing/NeedsAttention/Stuck/Idle) in sidebar
  - Auto-hides when all sessions are idle (no interesting filters)
  - Shows counts per category
- Cmd+K modal quick-switcher with fuzzy search across session metadata
  - Searches name, working directory, git branch, model
  - Keyboard navigation (arrows, Enter to select, Escape to close)
  - Shows status indicators and unread counts

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Change @onkeydown to @onkeyup on quick-switcher input (avoids selection clear on re-render)
- Add missing await on SelectQuickSwitcherSession call
- Add StateHasChanged() to CloseQuickSwitcher for proper backdrop-click dismissal

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add reflection cycle progress pill on cards (goal, iteration progress bar,
  stop button) -- previously only visible in expanded view
- Add stuck indicator banner when ConsecutiveStuckCount >= 1
- Add last-prompt preview for sessions with no loaded history
- CSS: card-reflection-pill (active/paused), card-stuck-banner, card-last-prompt

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rPrompt logic

- Restore @if (PendingImages.Any()) guard in SessionCard.razor
- Restore .attachment-strip { CSS selector in SessionCard.razor.css
- Move LastUserPrompt preview inside card-messages scope so cardMsgs.Count == 0
  logic works correctly (vs Session.History.Count == 0 which was always false)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rride

- Sessions with activity in last 48h auto-appear in sticky Focus strip at top of dashboard
- IsProcessing sessions always included in focus regardless of age
- FocusOverride enum on SessionMeta (Auto/Included/Excluded) for manual control
- SetFocusOverride() and GetFocusSessions() in CopilotService.Organization.cs
- Dismiss (✕) button on focus cards removes from strip (sets Excluded override)
- Context menu 'Add to Focus / Re-enable auto-focus' for manual inclusion
- Focus strip: horizontal flex, overflow-x:auto, sticky at top with z-index 100
- Fixed MoveSession declaration that was accidentally stripped during prior edit
- Fixed @{} in Razor else-block (moved to @if wrapper instead)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix DateTime.UtcNow to DateTime.Now for 48h cutoff (matches LastUpdatedAt storage)
- Add position: sticky, top: 0, z-index: 100, max-height: 30vh to .focus-strip
- Filter focus sessions out of normal group grid (no duplicates)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…roup filtering

- Focus auto-detection: processing + unread only (not 48h window that caught all 45 sessions)
- Cards in focus strip: flex: 0 0 380px, min-width: 380px, flex-shrink: 0 (no compression)
- Revert group filtering: focus sessions appear in both strip AND their group (overlay, not filter)
- Remove unused focusNames variable from focus strip block

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add +/- buttons in dashboard toolbar to set cards per row (1-6)
- Grid uses repeat(N, 1fr) based on user choice (default 3)
- GridColumns property persisted in UiState (ui-state.json)
- Restored on dashboard load with bounds validation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…mns everywhere

- Remove Compact/Expanded button per user request (too many UI controls)
- Fix Focus strip label from stale '48h' to 'Processing or unread'
- Apply grid-template-columns inline style to both main and external grids
- Remove dead .expanded-grid CSS rule

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…in 2 cols

- Change focus strip from flex (fixed 380px cards) to CSS grid with
  grid-template-columns controlled by _gridColumns
- Raise minimum columns from 1 to 2 (1 col makes cards too wide)
- Clamp persisted GridColumns < 2 back to default 3
- Both focus strip and group grids now respect the same column count
- Increase focus strip max-height from 30vh to 40vh for better visibility

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove overflow-y:hidden from focus strip (was clipping card content)
- Add min-height:250px to focus strip cards so they show messages
- Expand focus detection: IsProcessing OR UnreadCount>0 OR active in last 10min
  This catches orchestrator sessions between dispatches and sessions
  awaiting user response that aren't technically 'processing'
- Sort focus: processing first, then unread, then by recency

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Reduce recency window from 10min to 2min to avoid stale sessions
- Exclude worker sessions (Role==Worker) from focus strip — they show
  under their orchestrator in the group grid below
- Update hint text to 'Recently active'

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Root cause: LastUpdatedAt defaulted to DateTime.Now on session creation,
so every restored session appeared 'just updated' even if dormant for days.

- Add LastUpdatedAt to ActiveSessionEntry (persisted in active-sessions.json)
- Save LastUpdatedAt in both debounced and immediate save paths
- Restore real LastUpdatedAt during session restore (overrides DateTime.Now default)
- Change focus recency window from 2min to 24h as user requested
- Sessions truly idle for >24h will no longer appear in Focus strip

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two bugs fixed:
1. Sessions without a SessionMeta entry were excluded from Focus
   (line 660 returned false). Now sessions pass through to the
   activity check even without meta — catches newly created sessions
   and sessions where reconciliation hasn't run yet.
2. Focus strip was position:sticky with z-index:100, making it
   overlay content on scroll. Removed sticky positioning — Focus is
   now a normal inline section at the top of the dashboard.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sessions weren't appearing in Focus because LastUpdatedAt was only
updated by SDK event handlers. Two missing triggers:

1. SendPromptAsync: now sets LastUpdatedAt = DateTime.Now alongside
   IsProcessing, so the session enters Focus immediately on send.
2. SetActiveSession: now updates LastUpdatedAt when the user switches
   to a session, so just reading a session marks it active for 24h.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Workers were unconditionally excluded from Focus (line 662). But when
a user sends a message directly to a worker session, it should appear
in Focus since the user is actively interacting with it.

Changed: workers now show in Focus if they are processing or have
recent activity (LastUpdatedAt within 24h). Idle workers without
direct interaction remain hidden under their orchestrator.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Root cause: SessionMeta.Role defaulted to MultiAgentRole.Worker, and
the enum had no 'None' value. Every session created in any group was
born as a Worker, even standalone sessions in regular groups. This
caused the Focus strip to filter them out.

Changes:
1. Added MultiAgentRole.None as the default (ordinal 0)
2. Changed SessionMeta.Role default from Worker to None
3. Added Phase 4 to HealMultiAgentGroups: clears stale Worker/
   Orchestrator roles from sessions in non-multi-agent groups
4. GetFocusSessions now cross-checks group.IsMultiAgent before
   treating a Worker-labeled session as a real worker
5. Updated tests and fallback references

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Workers in actual multi-agent groups (group.IsMultiAgent=true) should
never appear in Focus — they show under their orchestrator card in the
group grid. Previous commit allowed them through if they had recent
activity, which cluttered Focus with 'PR Review Squad-worker-1' etc.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Three fixes for quick-switcher keyboard navigation:
1. Changed from @onkeyup to @onkeydown so arrow keys respond
   immediately (not on release) and can be prevented from moving
   the text cursor.
2. Added @onkeydown:preventDefault with a flag that's set only for
   ArrowUp/ArrowDown/Enter/Escape — prevents cursor jumping while
   still allowing normal typing.
3. Added scroll-into-view for the selected item so long result
   lists keep the highlight visible.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Blazor @onkeydown:preventDefault attribute is evaluated at render
time, not event time. This caused a race: arrow keys set the flag to
true, but the browser had already decided not to prevent default (it
was false at render). On the next render, preventDefault was true,
potentially blocking normal typing.

Fix: moved arrow/Enter/Escape handling to a JS keydown listener
installed when the quick-switcher opens. JS calls preventDefault
synchronously for navigation keys, then invokes C# via JSInterop
for state updates. Normal character keys pass through to @oninput.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1. Removed entire Cmd+K quick-switcher feature (HTML, CSS, JS, C#
   code). Dashboard focus is now purely on the reorganized grid.
2. Fixed external sessions grid having a duplicate closing quote/angle
   bracket in the HTML tag: ">" was rendered as literal text — the
   tiny '>' cursor visible in the screenshot.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Blazor DOM diffs from StateHasChanged/SafeRefreshAsync were causing
the dashboard to jump back to the top on every update. Now
SafeRefreshAsync saves the .dashboard scrollTop via JS before
triggering StateHasChanged, and OnAfterRenderAsync restores it.
Only applies in grid view (not expanded session mode).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… control

- saveCardScrollPositions() JS fn saves scrollTop per session key before StateHasChanged
- restoreDraftsAndFocus restores saved positions on re-render (no more scroll-to-top)
- forceScroll only scrolls to bottom when explicitly needed (new message sent)
- Add _cardMinHeight field (150-600px, step 50, default 250)
- Add CardMinHeight to UiState + SaveUiState, persisted to ui-state.json
- Add height +/- buttons in toolbar (next to column controls)
- Apply --card-min-height CSS variable to focus strip and grid cards

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PureWeen and others added 9 commits March 21, 2026 10:41
- Rename DefaultSessionMeta_RoleIsWorker -> DefaultSessionMeta_RoleIsNone
- Assert MultiAgentRole.None (the actual default) instead of Worker
- Update FocusOverride doc comments from '48h' to '24h' (matches GetFocusSessions)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…CSS variable

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Unknown enum string values now fall back to default(T) instead of
throwing, preventing entire organization payload deserialization
failures when desktop and mobile have mismatched enum definitions.

Applied to: FocusOverride, SessionSortMode, MultiAgentMode,
WorktreeStrategy, MultiAgentRole.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
F1: Fix locale-sensitive double in JS eval for scroll restoration
- Use CultureInfo.InvariantCulture when interpolating _pendingScrollTop
  into eval string to prevent '1234,5' on German/French locales

F2: Auto-reset status filter when filter bar hides
- Reset _statusFilter to All when processingCount/attentionCount/stuckCount
  all drop to zero, preventing empty sidebar with no visible recovery path

F3: Fix GetFocusSessions/SetFocusOverride missing _organizationLock
- Use SnapshotSessionMetas()/SnapshotGroups() in GetFocusSessions() for
  thread-safe reads; take lock in SetFocusOverride before FirstOrDefault

F4: Scope cardMenuSession/cardRenamingSession by focus strip prefix
- Use 'focus:' + session.Name as key for focus strip cards to prevent
  opening the context menu on both focus and grid cards simultaneously
- CommitCardRename strips the prefix before passing to RenameSession

Minor: Replace hardcoded MessageCount=15 on focus strip cards with
cardMessageCounts dict (same as main grid), so LoadMore works correctly

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
22 new tests covering:

Phase 4 healer (3 tests):
- HealMultiAgentGroups_Phase4_ClearsWorkerRoleFromNonMultiAgentGroup
- HealMultiAgentGroups_Phase4_ClearsOrchestratorRoleFromDeletedGroup
- HealMultiAgentGroups_Phase2_PromotesGroupWithPersistedOrchestratorRole
- HealMultiAgentGroups_Phase4_PreservesRolesInActualMultiAgentGroup

GetFocusSessions logic (6 tests):
- Returns processing sessions
- Returns sessions with unread messages
- FocusOverride.Included always shows
- FocusOverride.Excluded never shows (even if processing)
- Workers in real multi-agent groups excluded
- Processing sessions sort before unread in Focus strip

TolerantEnumConverter (9 tests):
- Unknown string values fall back to default
- Known string values (Worker, Orchestrator) deserialize correctly
- Missing fields fall back to default
- Case-insensitive matching
- Serializes as string not integer
- FocusOverride unknown value falls back to Auto
- MultiAgentMode unknown value falls back to default

UiState persistence (4 tests):
- GridColumns default is 3
- CardMinHeight default is 250
- GridColumns and CardMinHeight round-trip serialization
- Legacy JSON without new fields uses defaults

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sort order (oldest-waiting-first for triage workflow):
- Tier 0: Processing sessions (active work) — always at top
- Tier 1: Unhandled sessions — oldest LastUpdatedAt first (longest
  waiting at top, most urgent)
- Tier 2: Handled sessions — most recently handled at bottom

Replace ✕ 'Dismiss' button with ✓ 'Handled' button:
- Green circular button (matching the completion/success color)
- Sets HandledAt on SessionMeta → moves session to bottom of Focus
- HandledAt cleared automatically when CompleteResponse fires
  (new AI response = fresh activity, session moves back to top)
- SessionMeta.HandledAt persists across restarts

Rebase on origin/main (1 new commit from main merged cleanly).

Fix RenderThrottleTests regression — HandledAt clearing moved to
after OnStateChanged to stay within the source-order assertion
window that CompleteResponse_OnSessionComplete_FiresBeforeOnStateChanged
tests.

Add tests: GetFocusSessions_HandledSessions_SortToBottom,
GetFocusSessions_OldestWaitingFirst_WithinUnhandled,
MarkFocusHandled_SetsHandledAtOnMeta,
MarkFocusHandled_NonExistentSession_DoesNotThrow

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PP- prefix healer fix (Phase 1 suffix-match):
- Orchestrators like 'PP- PR Review Squad-orchestrator' have a scoped
  namespace prefix that their workers lack ('PR Review Squad-worker-N').
  Old Phase 1 required exact prefix match → roles cleared by Phase 4 →
  group permanently broken.
- Added IsWorkerForTeamPrefix() helper: tries exact prefix first, then
  falls back to suffix-match (workerPrefix is a suffix of orchPrefix).
- Phase 3 worker collection uses the same helper for reconstruction.
- Added HealerPrefixMatchTests (5 tests covering exact, suffix, no
  false-positive, no-duplicate-group, and full reconstruction cases).

WsBridge lock screen recovery:
- AcceptLoopAsync now catches HttpListenerException and sets _listener=null
  instead of breaking — the restart loop at the top of the method calls
  TryRestartListenerAsync() to revive the listener.
- TryRestartListenerAsync: brief 500ms delay, then tries wildcard and
  localhost prefixes with exponential back-off (max 30s) on repeat failures.
- RunKeepalivePingAsync: detects when Task.Delay woke >1.5x late (machine
  was suspended during lock screen) and triggers CheckConnectionHealthAsync.
- CheckConnectionHealthAsync: new public method — pings the headless server
  on resume; if ping fails in Persistent mode, fires TryRecoverPersistentServerAsync.
- App.OnResume: calls CheckConnectionHealthAsync so the connection is checked
  immediately when the user unlocks the Mac.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add namespace-prefix validation (must end with '- ') to prevent false suffix matches
- Workers excluded from Focus strip regardless of group state
- Add Phase1_SuffixMatch_NoFalsePositive test
- Fix WsBridge comment indentation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ing test

DashboardFeatureTests and HealerPrefixMatchTests both call
SetBaseDirForTesting() but were not in the BaseDir xUnit collection,
causing them to race with BaseDir-serialized tests and produce flaky
failures (HealMultiAgentGroups_DoesNothing_WhenGroupsAlreadyCorrect,
StateChangeCoalescerTests.SingleCall_FiresExactlyOnce).

- Add [Collection("BaseDir")] to DashboardFeatureTests and HealerPrefixMatchTests
- Restore TestSetup.TestBaseDir after each CreateServiceWithOrg() call
- Increase SingleCall_FiresExactlyOnce wait from 300ms → 600ms to survive
  heavy CI load (timer fires at 150ms; 300ms was borderline under full suite)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen PureWeen force-pushed the dashboard-session-management branch from 5f11f37 to 16b91f5 Compare March 21, 2026 15:46
@PureWeen
Copy link
Copy Markdown
Owner Author

🔄 Re-Review Round 3 — PR #413

Previous Findings Status

  • N1 🟡 Sort order comment (// Processing: most recent first but ThenBy is ascending = oldest first): STILL PRESENT at CopilotService.Organization.cs:783 — not addressed in the two new commits
  • N2 🟡 IsWorkerForTeamPrefix suffix false-positive: FIXED ✅ — namespace guard namespacePrefix.EndsWith("- ", Ordinal) correctly blocks "Review Squad" from claiming "Squad-worker-1"; Phase1_SuffixMatch_NoFalsePositive test added
  • N3 🟢 Permanent Focus exclusion design note: N/A

New Commits Review (c5753d1 + 16b91f5)

Worker exclusion simplification (c5753d12): Changed from "only exclude workers in active multi-agent groups" to "exclude all workers unconditionally." Correct — covers the case where a worker's group was lost but Role=Worker wasn't cleared yet. FocusOverride.Included correctly takes precedence (checked before worker exclusion), giving users an explicit escape hatch if needed.

MultiAgentRole.None as first enum value: Old organization.json files without a Role field deserialize to default(MultiAgentRole) = value 0. Previously 0 == Worker (mislabeling). Now 0 == None (correct). The previous converter was JsonStringEnumConverter which wrote strings (not numeric), so this migration concern is limited to edge-case hand-edited files. Net result: a bug fix for mislabeled sessions.

Test isolation (16b91f53): [Collection("BaseDir")] added to DashboardFeatureTests and HealerPrefixMatchTests — prevents race with BaseDir-serialized tests that was causing flaky failures. Coalescer timing 300ms→600ms (4× headroom over 150ms timer) is appropriate.

New Issues (consensus: 2+ of 5 models required)

None. No new consensus issues found in the two new commits.

(Non-consensus note from 1 model: var groups = SnapshotGroups().ToDictionary(...) at line 752 became a dead variable after c5753d1 removed the only consumer — minor unnecessary allocation on each Focus strip render. Not blocking.)

Tests

2897 passed, 0 failed (full suite, including all WsBridgeIntegrationTests and DashboardFeatureTests)

Verdict

Approve

N1 (the sort comment) is the sole remaining nit from Round 2 — it's a misleading inline comment with no behavioral impact (the Focus strip renders correctly; in practice users rarely have multiple simultaneous processing sessions). Both N2 blocking issues from Round 2 are resolved, all Round 1 findings remain fixed, and the test suite is fully green. This is ready to merge.

…ck consistency

- GetFocusSessions: invert Processing-tier sort key via DateTime.MaxValue.Ticks - ticks
  so newest processing session appears first (matches comment 'most recent first')
- MarkFocusHandled: move HandledAt write inside _organizationLock for consistency
  with SaveOrganizationCore snapshot pattern

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen
Copy link
Copy Markdown
Owner Author

🔄 Re-Review Round 3 — PR #413

Previous Findings Status

  • F1 🟡 Locale scrollTop in JS eval: FIXED ✅ (Round 2)
  • F2 🟡 _statusFilter not reset: FIXED ✅ (Round 2)
  • F3 🟡 GetFocusSessions lockless reads: FIXED ✅ (Round 2)
  • F4 🟡 Shared cardMenuSession across strips: FIXED ✅ (Round 2)
  • N1 🟡 Processing-tier sort comment/behavior mismatch: FIXED ✅ — DateTime.MaxValue.Ticks - s.LastUpdatedAt.Ticks inversion in ThenBy now gives newest-first for Processing sessions, matching the comment. Committed as 17f6b1d4.
  • N2 🟡 IsWorkerForTeamPrefix suffix false positive: FIXED ✅ — namespacePrefix.EndsWith("- ", Ordinal) guard correctly rejects "Review Squad" → "Squad-worker-1" while accepting "PP- Squad" → "Squad-worker-1". 6/6 HealerPrefixMatchTests pass.
  • N3 🟢 FocusOverride.Excluded unreachable: N/A — confirmed design decision.

New Fix (consensus 2+/5 models, committed in 17f6b1d4)

MarkFocusHandled lock consistencymeta.HandledAt = DateTime.Now was written outside _organizationLock in the original commit, while SaveOrganizationCore snapshots Organization.Sessions under that lock on a background thread. Nullable<DateTime> is a 16-byte struct (not atomically writable), so a concurrent snapshot could observe a torn value. Fix: moved the write inside the lock block.

  • Assertion at class-init.c:4675, condition `parent' not met

=================================================================
Native Crash Reporting

Got a abrt while executing native code. This usually indicates
a fatal error in the mono runtime or one of the native libraries
used by your application.

=================================================================
Native stacktrace:

0x104ae9679 - /Library/Frameworks/Mono.framework/Versions/6.12.0/bin/mono-sgen64 : mono_dump_native_crash_info
0x104a8140e - /Library/Frameworks/Mono.framework/Versions/6.12.0/bin/mono-sgen64 : mono_handle_native_crash
0x104ae8c3f - /Library/Frameworks/Mono.framework/Versions/6.12.0/bin/mono-sgen64 : sigabrt_signal_handler
0x7ff815d0837d - /usr/lib/system/libsystem_platform.dylib : _sigtramp
0x30db715d0 - Unknown
0x7ff815c0e3a6 - /usr/lib/system/libsystem_c.dylib : abort
0x104cd5ec7 - /Library/Frameworks/Mono.framework/Versions/6.12.0/bin/mono-sgen64 : monoeg_assert_abort
0x104cb6d2f - /Library/Frameworks/Mono.framework/Versions/6.12.0/bin/mono-sgen64 : mono_log_write_logfile
0x104cd635e - /Library/Frameworks/Mono.framework/Versions/6.12.0/bin/mono-sgen64 : monoeg_g_logv_nofree
0x104cd64df - /Library/Frameworks/Mono.framework/Versions/6.12.0/bin/mono-sgen64 : monoeg_assertion_message
0x104cd651a - /Library/Frameworks/Mono.framework/Versions/6.12.0/bin/mono-sgen64 : mono_assertion_message
0x104b6ff3d - /Library/Frameworks/Mono.framework/Versions/6.12.0/bin/mono-sgen64 : mono_class_setup_parent
0x104b6f497 - /Library/Frameworks/Mono.framework/Versions/6.12.0/bin/mono-sgen64 : mono_class_create_from_typedef
0x104b67aac - /Library/Frameworks/Mono.framework/Versions/6.12.0/bin/mono-sgen64 : mono_class_get_checked
0x104b6874b - /Library/Frameworks/Mono.framework/Versions/6.12.0/bin/mono-sgen64 : mono_class_from_name_checked_aux
0x104b687cc - /Library/Frameworks/Mono.framework/Versions/6.12.0/bin/mono-sgen64 : mono_class_from_name_checked_aux
0x104b63440 - /Library/Frameworks/Mono.framework/Versions/6.12.0/bin/mono-sgen64 : mono_class_load_from_name
0x104b57f17 - /Library/Frameworks/Mono.framework/Versions/6.12.0/bin/mono-sgen64 : mono_init_internal
0x1049dbbc3 - /Library/Frameworks/Mono.framework/Versions/6.12.0/bin/mono-sgen64 : mini_init
0x104a41072 - /Library/Frameworks/Mono.framework/Versions/6.12.0/bin/mono-sgen64 : mono_main
0x1049cdb78 - /Library/Frameworks/Mono.framework/Versions/6.12.0/bin/mono-sgen64 : main
0x205092781 - Unknown

=================================================================
Telemetry Dumper:

=================================================================
Basic Fault Address Reporting

Memory around native instruction pointer (0x7ff815cc182e):0x7ff815cc181e ff ff c3 00 00 00 b8 48 01 00 02 49 89 ca 0f 05 .......H...I....
0x7ff815cc182e 73 08 48 89 c7 e9 70 9a ff ff c3 00 00 00 b8 53 s.H...p........S
0x7ff815cc183e 00 00 02 49 89 ca 0f 05 73 08 48 89 c7 e9 58 9a ...I....s.H...X.
0x7ff815cc184e ff ff c3 00 00 00 b8 83 01 00 02 49 89 ca 0f 05 ...........I....

Consensus: Opus-2 (MEDIUM) + Codex (HIGH) — 2/5


New Findings (informational only, below consensus threshold)

  • Dead allocation (CopilotService.Organization.cs:~751): var groups = SnapshotGroups().ToDictionary(g => g.Id) is allocated in GetFocusSessions() but never accessed. The worker role check was simplified to meta.Role == MultiAgentRole.Worker in a later commit, making the groups dictionary dead code. Worth cleaning up but non-blocking. (1/5 — Opus-2 only)

  • WsBridge wildcard-first restart emits spurious log (WsBridgeServer.cs:~284): On localhost-only machines (macOS), TryRestartListenerAsync tries http://+:{port}/ first (which always fails) before falling back to localhost, logging a confusing "restart failed" before succeeding. No correctness impact. (1/5 — Sonnet only)


Tests

2897 passed, 0 failed — full suite including 26 DashboardFeatureTests and 6 HealerPrefixMatchTests

CI Status

⚠️ No CI checks configured on this branch.

Verdict

Approve — all Round 1–3 findings resolved. Two minor fixes committed (17f6b1d4): Processing sort direction corrected and HandledAt lock consistency improved. Dead groups allocation is the only remaining cleanup item (non-blocking).

@PureWeen
Copy link
Copy Markdown
Owner Author

🔄 Re-Review Round 3 — PR #413

Previous Findings Status

  • F1 Locale bug in scroll eval: FIXED
  • F2 Filter stuck on hide: FIXED
  • F3 GetFocusSessions lockless reads: FIXED
  • F4 Shared menu state across focus strip + grid: FIXED
  • N1 Sort comment mismatch (Processing tier): STILL OPEN
  • N2 IsWorkerForTeamPrefix suffix match false positive: FIXED ✅ — namespacePrefix.EndsWith("- ") guard confirmed correct

New Commits Since Round 2

Commit Change Status
aba39843 PP- prefix healer (IsWorkerForTeamPrefix) + WsBridge lock-screen recovery
c5753d12 Suffix-match false-positive guard (namespacePrefix.EndsWith("- ")) ✅ (N2 fix)
16b91f53 Test isolation: [Collection("BaseDir")] on DashboardFeatureTests / HealerPrefixMatchTests

All three are sound. No new bugs introduced.


Outstanding Issues (consensus: 2+ of 5 models)

🟢 N1 (5/5 consensus) — Wrong inline comment in GetFocusSessions Processing tier
CopilotService.Organization.cs:783

if (s.IsProcessing) return s.LastUpdatedAt; // Processing: most recent first  ← WRONG

ThenBy is ascending, so s.LastUpdatedAt ascending = oldest first (longest-running session at top). The comment says "most recent first" which would require ThenByDescending. The adjacent line 784 correctly documents the same direction as "oldest first (ascending = longest wait at top)", making the contradiction obvious.

The behavior itself is likely intentional — showing the longest-running active job first is good triage UX, consistent with the Unhandled tier. The commit message for 47799ab1 confirms "oldest-waiting-first for triage workflow". Fix is one word: update the comment to // oldest first (longest-running at top).


🟢 N3 (3/5 consensus) — HttpListener not disposed on failed Start() in TryRestartListenerAsync
WsBridgeServer.cs:~289

var listener = new HttpListener();
listener.Prefixes.Add(prefix);
listener.Start();   // throws → catch logs and continues
// listener never disposed

If the wildcard prefix (http://+:PORT/) fails, the HttpListener instance is abandoned. Start() failure leaves no kernel handles open, so GC finalization eventually cleans it up — but calling listener.Dispose() in the catch is the correct pattern for IDisposable. Low risk; noting for correctness.


Tests

2896 passed, 1 failure (TurnEndFallbackTests.FallbackTimer_NotCancelled_FiresAfterDelay) — passes in isolation, pre-existing timing flake under full-suite load, not PR-specific.

All 54 HealerPrefixMatchTests and DashboardFeatureTests pass. [Collection("BaseDir")] isolation fix effective.


Verdict

⚠️ Request changes — one actionable item:

  1. N1 (one-word fix): Change the inline comment on line 783 from // Processing: most recent first to // oldest first (longest-running at top) to match the actual ascending behavior and stated triage intent.

N3 (HttpListener dispose) is a minor style correctness note, not a blocker. All functional issues from prior rounds are resolved. The new PP- prefix healer, WsBridge lock-screen recovery, and test isolation fixes are all correct.

@PureWeen
Copy link
Copy Markdown
Owner Author

🔄 Re-Review Round 4 — PR #413

Previous Finding Status

  • N1 🟡 Sort order comment / direction mismatch: FIXED ✅ — commit 17f6b1d4 inverts the Processing tier sort key via DateTime.MaxValue.Ticks - s.LastUpdatedAt.Ticks, making newest processing session sort first. Returns long (ticks) consistently across all three branches. Comment now accurate.
  • Also in same commit: MarkFocusHandled moves the HandledAt = DateTime.Now write inside _organizationLock for full lock consistency.

All Findings Resolved

Finding Status
F1 🟡 Locale bug in JS eval ✅ FIXED (Round 1)
F2 🟡 _statusFilter not reset ✅ FIXED (Round 1)
F3 🟡 GetFocusSessions missing lock ✅ FIXED (Round 1)
F4 🟡 Shared card menu/rename state ✅ FIXED (Round 1)
N1 🟡 Processing sort direction/comment ✅ FIXED (Round 4)
N2 🟡 IsWorkerForTeamPrefix suffix false-positive ✅ FIXED (Round 3)

Tests

2897 passed, 0 failed (full suite)

Verdict: ✅ Approve

All findings resolved across 4 rounds. No remaining issues. Ready to merge.

@PureWeen PureWeen merged commit 8a9870a into main Mar 21, 2026
@PureWeen PureWeen deleted the dashboard-session-management branch March 21, 2026 19:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant