Skip to content

Phase 1: Add AppChat sessions powered by on-device SLM#204

Draft
mattleibow wants to merge 13 commits intomainfrom
feature/phase1-appchat
Draft

Phase 1: Add AppChat sessions powered by on-device SLM#204
mattleibow wants to merge 13 commits intomainfrom
feature/phase1-appchat

Conversation

@mattleibow
Copy link
Copy Markdown
Collaborator

@mattleibow mattleibow commented Feb 24, 2026

Implement local AI-powered chat sessions using Microsoft.Extensions.AI and Microsoft Agent Framework. AppChat sessions appear alongside Copilot sessions in the sidebar with distinct visual treatment.

New files:

Key changes:

  • SessionState.Session is now nullable (CopilotSession?)
  • AgentSessionInfo gains Kind property (defaults to Copilot)
  • CopilotService routes SendPromptAsync by SessionKind
  • MauiProgram registers keyed 'local' IChatClient pipeline
  • ExpandedSessionView hides Copilot-only controls for AppChat (Fiesta, ReflectionCycle, Plan/Autopilot, model selector)
  • Sidebar gets '✨ App Chat' button in both desktop and flyout

Found a bug: dotnet/maui#34124

@mattleibow mattleibow force-pushed the feature/phase1-appchat branch from 843eb87 to 939eff9 Compare February 24, 2026 16:25
- Revert SessionKind enum, nullable CopilotSession, and routing changes
- Revert ExpandedSessionView/SessionSidebar AppChat integration
- Create AppChatPopover.razor — floating overlay with ChatMessageList reuse
- Add ✨ FAB toggle in MainLayout
- Keep AI services: DirectLocalChatService, AppChatTools, NonFunctionInvokingChatClient
- Remove Kind reference from AppChatTools (SessionKind deleted)
- 610 tests passing, build clean

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mattleibow mattleibow force-pushed the feature/phase1-appchat branch from 939eff9 to 1d7995b Compare February 24, 2026 18:21
mattleibow and others added 12 commits February 24, 2026 20:26
…Dashboard sync

The tool was calling SetActiveSession which only sets the name, but the
Dashboard's expandedSession local state wasn't updating. Now uses
SwitchSession + SaveUiState (matching sidebar's SelectSession pattern)
so the Dashboard picks up the expanded session correctly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The RefreshState else-if guard required expandedSession != null, which
meant switching from grid view (expandedSession == null) never expanded
the target session. Removed the null guard so the active session always
expands when changed programmatically (matching sidebar behavior).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add switchKeepAliveSlot JS call in RefreshState sync path so the
  main pane actually switches when active session changes programmatically
- Remove collapsible header from AppChatPopover (toggle via header button)
- Remove close button and OnClose callback (header button toggles)

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

Two bugs fixed:

1. NonFunctionInvokingChatClient: Apple Intelligence crashes on
   FunctionCallContent in conversation history from previous turns.
   Added StripToolContent() to filter these from input messages before
   they reach the Apple Intelligence client.

2. Dashboard RefreshState: the sessionSwitched path didn't call
   switchKeepAliveSlot JS because it assumed the capture-phase click
   handler already did it. Programmatic switches (from tools) have no
   click event, so the main pane slot never toggled. Added the JS call
   to both the sessionSwitched and sync paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add onToolStart/onToolEnd callbacks to DirectLocalChatService
- Track ToolActivity list and CurrentToolName in AppChatPopover
- Pass ToolActivities and CurrentToolName to ChatMessageList
- Tool calls now show the same expandable activity indicators as main chat

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Tool calls now appear as expandable messages in the chat history
  (ToolCallMessage on start, marked complete with result on end)
- Fix horizontal scroll: overflow-x hidden on body, pre-wrap on code
  blocks, word-break on message text

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- onToolStart now passes arguments JSON (3rd param)
- ToolCallMessage created with args so tapping shows input
- Tool result stored as full Content (no truncation) for expandable view
- ToolActivity.Input populated for activity display

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Compact=true was rendering tool calls as simple one-liners without
expandable args/output. Set Compact=false to use the same action-box
rendering as the main chat with clickable headers, args, and output.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Resolve conflicts keeping both AppChat and Statistics/info-popover features.
Update Microsoft.Maui.Essentials.AI to 10.0.50-ci.main.26153.3.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
MEAI now handles Apple Intelligence tool calls natively without the
double-invocation workaround (dotnet/extensions#7204 resolved).
Matches the updated MAUI AI sample which also removed this class.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Switch ChatMessageList to Compact=true for condensed message rendering
  suitable for large tool call messages in the small popover
- Increase popover width from 380px to 520px
- Switch max-height from fixed 560px to 72vh (viewport-relative)
- Update appchat-body max-height to match (calc(72vh - 100px))

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

PureWeen commented Apr 3, 2026

🔍 Multi-Model Code Review — PR #204

PR: Phase 1: Add AppChat sessions powered by on-device SLM
Reviewers: Claude Opus 4.6 · Claude Sonnet 4.6 · GPT-5.3-Codex
CI Status: ⚠️ No CI checks configured on this branch
Prior Reviews: None
Files: 11 changed (+611/−6)


Consensus Findings (2+ of 3 models)

🔴 CRITICAL — DI crash on Android/Windows

File: AppChatPopover.razor L4, MauiProgram.cs RegisterLocalAI
Flagged by: Opus 🟥 · Sonnet 🟥 · Codex 🟥 (3/3)

@inject DirectLocalChatService LocalChat is unconditional in AppChatPopover.razor, which is rendered from MainLayout.razor on all platforms. But DirectLocalChatService is only registered inside #if MACCATALYST || IOS. On Android/Windows, the DI container throws InvalidOperationException at component creation — crashing the entire layout.

Fix: Either (a) guard <AppChatPopover> with a platform check, (b) register a no-op stub on non-Apple platforms, or (c) conditionally compile the component.


🔴 CRITICAL — Missing @implements IDisposable — Dispose() never called

File: AppChatPopover.razorDispose() method
Flagged by: Opus 🟥 · Sonnet 🟡 · Codex 🟡 (3/3)

The component defines public void Dispose() (cancels CTS, removes session) but has no @implements IDisposable directive. Blazor will never call Dispose() on teardown. Result: CancellationTokenSource leaks, and AgentSession entries accumulate forever in DirectLocalChatService._sessions.

Fix: Add @implements IDisposable at the top of the file.


🟡 MODERATE — SwitchToSession tool mutates UI state from background thread

File: AppChatTools.csSwitchToSession method (~lines 63–72)
Flagged by: Opus 🟡 · Sonnet 🟥 · Codex 🟡 (3/3)

SwitchToSession calls service.SwitchSession() (fires OnStateChanged) and service.SaveUiState() from the AI agent's tool execution context — a background thread. The codebase requires all CopilotService UI mutations to be marshaled through InvokeOnUI(). Also mutates session.LastReadMessageCount without synchronization while the UI thread may be reading it.

Fix: Tool should return data only; the component should apply state changes on the UI thread. Or post the switch via SynchronizationContext.


🟡 MODERATE — GetOrCreateAgent() race condition — double-create

File: DirectLocalChatService.cs — lines 41–48
Flagged by: Opus 🟡 · Sonnet 🟡 · Codex 🟥 (3/3)

if (_agent is not null) return _agent;
// ← two threads can both pass the null check
_agent = new ChatClientAgent(...);

No lock or volatile on _agent. Two concurrent calls create two agents, one silently discarded. Use Lazy<T> or Interlocked.CompareExchange.


🟡 MODERATE — GetOrCreateSessionAsync() TOCTOU race

File: DirectLocalChatService.cs — lines 50–58
Flagged by: Opus 🟡 · Sonnet 🟡 · Codex 🟥 (3/3)

if (_sessions.TryGetValue(sessionName, out var existing)) return existing;
var session = await agent.CreateSessionAsync();  // yields — widens race window
_sessions[sessionName] = session;

Two concurrent calls for the same key both miss TryGetValue, both create sessions, one is silently discarded (resource leak).

Fix: Use GetOrAdd with lazy factory, or serialize per-key creation.


🟡 MODERATE — Background thread mutations of component state

File: AppChatPopover.razor — all callbacks in SendMessage (~lines 80–143)
Flagged by: Opus 🟡 · Sonnet 🟥 · Codex (implicit in DI finding) (2/3)

The onComplete, onError, onToolStart, onToolEnd callbacks mutate messages (a non-thread-safe List<T>), isProcessing, streamingContent, etc. before calling InvokeAsync(StateHasChanged). If Blazor's render cycle enumerates messages concurrently, InvalidOperationException or silent corruption results.

Fix: Move all state mutations inside the InvokeAsync callback.


🟡 MODERATE — Dashboard guard removal changes behavior

File: Dashboard.razor — else-if condition (~line 958)
Flagged by: Opus 🟡 · Sonnet 🟡 · Codex 🟡 (3/3)

Removing expandedSession != null means when expandedSession is null and active is non-null, the branch now fires (null != activetrue), auto-expanding sessions even when the user hadn't expanded anything. Also calls switchKeepAliveSlot for a potentially invalid slot.


🟢 MINOR — CancellationTokenSource not disposed before reassignment

File: AppChatPopover.razorSendMessage (~lines 87–89)
Flagged by: Opus 🟢 · Sonnet 🟢 · Codex 🟢 (3/3)

_cts?.Cancel() is called but Dispose() is not, before creating a new CTS. Leaks timer handles.

Fix: _cts?.Cancel(); _cts?.Dispose(); _cts = new CancellationTokenSource();


🟢 MINOR — No tests for new code paths

File: PolyPilot.Tests/
Flagged by: Opus (implicit) · Sonnet (implicit) · Codex 🟢 (3/3)

Zero test coverage for DirectLocalChatService (concurrency, threading), AppChatTools (UI-thread safety), or AppChatPopover (lifecycle, DI gating). The test csproj adds <Compile Include> links for the new files but no actual test classes.


Summary

# Severity Finding Models
1 🔴 DI crash on Android/Windows 3/3
2 🔴 Missing @implements IDisposable 3/3
3 🟡 SwitchToSession background thread mutation 3/3
4 🟡 GetOrCreateAgent race condition 3/3
5 🟡 GetOrCreateSessionAsync TOCTOU race 3/3
6 🟡 Callback state mutations off UI thread 2/3
7 🟡 Dashboard guard removal regression 3/3
8 🟢 CTS not disposed before reassignment 3/3
9 🟢 No test coverage for new code 3/3

Recommended Action: ⚠️ Request Changes

The two CRITICAL issues must be fixed before merge:

  1. DI crash: DirectLocalChatService injection will crash the app on Android/Windows
  2. Missing IDisposable: Resource and session leaks on every component teardown

The 5 MODERATE issues (thread safety, race conditions, dashboard regression) should also be addressed — they will cause intermittent bugs under real-world use.

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.

2 participants