diff --git a/PolyPilot.Tests/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj
index d94efb19..d385dc27 100644
--- a/PolyPilot.Tests/PolyPilot.Tests.csproj
+++ b/PolyPilot.Tests/PolyPilot.Tests.csproj
@@ -13,6 +13,8 @@
+
+
@@ -76,6 +78,8 @@
+
+
diff --git a/PolyPilot/Components/AppChatPopover.razor b/PolyPilot/Components/AppChatPopover.razor
new file mode 100644
index 00000000..9753fafb
--- /dev/null
+++ b/PolyPilot/Components/AppChatPopover.razor
@@ -0,0 +1,164 @@
+@using PolyPilot.Models
+@using PolyPilot.Services
+@using PolyPilot.Services.AI
+@inject DirectLocalChatService LocalChat
+@inject IJSRuntime JS
+
+@if (IsVisible)
+{
+
+
+
+
+
+
+
+
+
+
+
+
+}
+
+@code {
+ [Parameter] public bool IsVisible { get; set; }
+
+ private List messages = new();
+ private List toolActivities = new();
+ private string streamingContent = "";
+ private string currentToolName = "";
+ private bool isProcessing;
+ private string sessionName = "appchat-" + Guid.NewGuid().ToString("N")[..8];
+ private CancellationTokenSource? _cts;
+
+ private void ClearChat()
+ {
+ _cts?.Cancel();
+ messages.Clear();
+ toolActivities.Clear();
+ streamingContent = "";
+ currentToolName = "";
+ isProcessing = false;
+ LocalChat.RemoveSession(sessionName);
+ sessionName = "appchat-" + Guid.NewGuid().ToString("N")[..8];
+ }
+
+ private async Task HandleKeyDown(KeyboardEventArgs e)
+ {
+ if (e.Key == "Enter" && !isProcessing)
+ await SendMessage();
+ }
+
+ private async Task SendMessage()
+ {
+ var prompt = await JS.InvokeAsync("eval", "document.getElementById('appchat-input')?.value || ''");
+ if (string.IsNullOrWhiteSpace(prompt)) return;
+
+ await JS.InvokeVoidAsync("eval", "document.getElementById('appchat-input').value = ''");
+
+ messages.Add(ChatMessage.UserMessage(prompt));
+ isProcessing = true;
+ streamingContent = "";
+ StateHasChanged();
+
+ _cts?.Cancel();
+ _cts = new CancellationTokenSource();
+
+ await LocalChat.SendPromptStreamingAsync(
+ sessionName,
+ prompt,
+ onDelta: delta =>
+ {
+ // Clear tool state when text starts streaming
+ if (currentToolName != "")
+ {
+ currentToolName = "";
+ }
+ streamingContent += delta;
+ InvokeAsync(StateHasChanged);
+ },
+ onComplete: full =>
+ {
+ messages.Add(ChatMessage.AssistantMessage(full));
+ streamingContent = "";
+ currentToolName = "";
+ toolActivities.Clear();
+ isProcessing = false;
+ InvokeAsync(StateHasChanged);
+ },
+ onError: error =>
+ {
+ messages.Add(ChatMessage.ErrorMessage(error));
+ streamingContent = "";
+ currentToolName = "";
+ toolActivities.Clear();
+ isProcessing = false;
+ InvokeAsync(StateHasChanged);
+ },
+ onToolStart: (name, callId, argsJson) =>
+ {
+ currentToolName = name;
+ messages.Add(ChatMessage.ToolCallMessage(name, callId, argsJson));
+ toolActivities.Add(new ToolActivity
+ {
+ Name = name,
+ CallId = callId,
+ Input = argsJson,
+ StartedAt = DateTime.Now
+ });
+ InvokeAsync(StateHasChanged);
+ },
+ onToolEnd: (callId, result) =>
+ {
+ var activity = toolActivities.FirstOrDefault(a => a.CallId == callId);
+ if (activity != null)
+ {
+ activity.IsComplete = true;
+ activity.IsSuccess = true;
+ activity.CompletedAt = DateTime.Now;
+ activity.Result = result;
+ }
+ var toolMsg = messages.LastOrDefault(m => m.ToolCallId == callId);
+ if (toolMsg != null)
+ {
+ toolMsg.IsComplete = true;
+ toolMsg.IsSuccess = true;
+ toolMsg.Content = result ?? "";
+ toolMsg.IsCollapsed = true;
+ }
+ currentToolName = "";
+ InvokeAsync(StateHasChanged);
+ },
+ cancellationToken: _cts.Token);
+ }
+
+ public void Dispose()
+ {
+ _cts?.Cancel();
+ _cts?.Dispose();
+ LocalChat.RemoveSession(sessionName);
+ }
+}
diff --git a/PolyPilot/Components/AppChatPopover.razor.css b/PolyPilot/Components/AppChatPopover.razor.css
new file mode 100644
index 00000000..c65eec7b
--- /dev/null
+++ b/PolyPilot/Components/AppChatPopover.razor.css
@@ -0,0 +1,137 @@
+.appchat-popover {
+ position: fixed;
+ top: 50px;
+ left: 16px;
+ width: 520px;
+ max-height: 72vh;
+ background: var(--bg-primary, #1e1e2e);
+ border: 1px solid var(--border-color, #444);
+ border-radius: 12px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
+ z-index: 200;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.appchat-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 14px;
+ background: var(--bg-secondary, #2a2a3e);
+ border-bottom: 1px solid var(--border-color, #444);
+ flex-shrink: 0;
+}
+
+.appchat-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary, #e0e0e0);
+}
+
+.appchat-header-actions {
+ display: flex;
+ gap: 4px;
+}
+
+.appchat-clear-btn {
+ background: none;
+ border: none;
+ color: var(--text-secondary, #999);
+ cursor: pointer;
+ font-size: 13px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ line-height: 1;
+}
+
+.appchat-clear-btn:hover {
+ background: var(--bg-hover, rgba(255, 255, 255, 0.1));
+ color: var(--text-primary, #e0e0e0);
+}
+
+.appchat-body {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ min-height: 200px;
+ max-height: calc(72vh - 100px);
+ padding: 8px;
+}
+
+/* Force text and code blocks to wrap within the popover */
+.appchat-body pre,
+.appchat-body code {
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow-x: hidden;
+}
+
+.appchat-body .chat-msg-text {
+ overflow-x: hidden;
+ word-break: break-word;
+}
+
+.appchat-input-area {
+ display: flex;
+ gap: 6px;
+ padding: 8px 10px;
+ border-top: 1px solid var(--border-color, #444);
+ background: var(--bg-secondary, #2a2a3e);
+ flex-shrink: 0;
+}
+
+.appchat-input-area input {
+ flex: 1;
+ background: var(--bg-primary, #1e1e2e);
+ border: 1px solid var(--border-color, #555);
+ border-radius: 6px;
+ padding: 6px 10px;
+ color: var(--text-primary, #e0e0e0);
+ font-size: 13px;
+ outline: none;
+}
+
+.appchat-input-area input:focus {
+ border-color: var(--accent-color, #7c3aed);
+}
+
+.appchat-input-area input:disabled {
+ opacity: 0.5;
+}
+
+.appchat-send-btn {
+ background: var(--accent-color, #7c3aed);
+ border: none;
+ border-radius: 6px;
+ color: white;
+ padding: 6px 12px;
+ cursor: pointer;
+ font-size: 14px;
+ line-height: 1;
+}
+
+.appchat-send-btn:hover:not(:disabled) {
+ opacity: 0.85;
+}
+
+.appchat-send-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+/* Mobile adjustments */
+@media (max-width: 768px) {
+ .appchat-popover {
+ top: 50px;
+ left: 8px;
+ right: 8px;
+ width: auto;
+ max-height: 50vh;
+ }
+
+ .appchat-body {
+ max-height: 35vh;
+ }
+}
diff --git a/PolyPilot/Components/Layout/MainLayout.razor b/PolyPilot/Components/Layout/MainLayout.razor
index 347be655..94726c68 100644
--- a/PolyPilot/Components/Layout/MainLayout.razor
+++ b/PolyPilot/Components/Layout/MainLayout.razor
@@ -5,12 +5,12 @@
-
+
@@ -21,15 +21,18 @@
-
+
+
+
@code {
private bool flyoutOpen;
+ private bool appChatOpen;
private bool showStatistics;
private int fontSize = 20;
private Timer? _holidayTimer;
@@ -201,6 +204,11 @@
flyoutOpen = false;
}
+ private void ToggleAppChat()
+ {
+ appChatOpen = !appChatOpen;
+ }
+
private void ToggleStatistics()
{
showStatistics = !showStatistics;
diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor b/PolyPilot/Components/Layout/SessionSidebar.razor
index 08565e6e..5d2e716d 100644
--- a/PolyPilot/Components/Layout/SessionSidebar.razor
+++ b/PolyPilot/Components/Layout/SessionSidebar.razor
@@ -56,6 +56,7 @@ else
+
@@ -193,6 +194,7 @@ else
[Parameter] public bool IsFlyoutPanel { get; set; }
[Parameter] public EventCallback OnToggleFlyout { get; set; }
[Parameter] public EventCallback OnSessionSelected { get; set; }
+ [Parameter] public EventCallback OnToggleAppChat { get; set; }
[Parameter] public int FontSize { get; set; } = 20;
[Parameter] public EventCallback OnFontSizeChange { get; set; }
[Parameter] public EventCallback OnToggleStatistics { get; set; }
diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor.css b/PolyPilot/Components/Layout/SessionSidebar.razor.css
index 4aa6a183..9af1fe7f 100644
--- a/PolyPilot/Components/Layout/SessionSidebar.razor.css
+++ b/PolyPilot/Components/Layout/SessionSidebar.razor.css
@@ -1104,6 +1104,13 @@
color: var(--accent-primary);
}
+.appchat-toggle {
+ font-size: 12px;
+ border: none;
+ background: none;
+ cursor: pointer;
+}
+
.info-popover { position: relative; display: flex; align-items: center; }
.info-trigger {
cursor: pointer;
diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor
index 58a8e283..1298ff27 100644
--- a/PolyPilot/Components/Pages/Dashboard.razor
+++ b/PolyPilot/Components/Pages/Dashboard.razor
@@ -953,13 +953,15 @@
_focusedInputId = $"input-{active!.Replace(" ", "-")}";
_needsScrollToBottom = true;
CopilotService.SaveUiState("/dashboard", activeSession: active, expandedSession: active, expandedGrid: !isCompactGrid);
+ _ = InvokeAsync(() => JS.InvokeVoidAsync("switchKeepAliveSlot", $"slot-{active.Replace(" ", "-")}", active));
}
- // Ensure expandedSession stays in sync with active session (e.g., sidebar flyout selection on mobile)
- else if (active != null && expandedSession != null && expandedSession != active && sessions.Any(s => s.Name == active))
+ // Ensure expandedSession stays in sync with active session (e.g., sidebar flyout selection on mobile, tool switch)
+ else if (active != null && expandedSession != active && sessions.Any(s => s.Name == active))
{
_lastActiveSession = active;
expandedSession = active;
_needsScrollToBottom = true;
+ _ = InvokeAsync(() => JS.InvokeVoidAsync("switchKeepAliveSlot", $"slot-{active.Replace(" ", "-")}", active));
}
else if (sessionSwitched && active == null)
{
diff --git a/PolyPilot/MauiProgram.cs b/PolyPilot/MauiProgram.cs
index 10851132..32c655d4 100644
--- a/PolyPilot/MauiProgram.cs
+++ b/PolyPilot/MauiProgram.cs
@@ -1,8 +1,13 @@
using PolyPilot.Services;
+using PolyPilot.Services.AI;
+using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using ZXing.Net.Maui.Controls;
using MauiDevFlow.Agent;
using MauiDevFlow.Blazor;
+#if MACCATALYST || IOS
+using Microsoft.Maui.Essentials.AI;
+#endif
using CommunityToolkit.Maui;
using CommunityToolkit.Maui.Media;
#if MACCATALYST
@@ -111,6 +116,9 @@ public static MauiApp CreateMauiApp()
builder.Services.AddSingleton(SpeechToText.Default);
builder.Services.AddSingleton();
+ // Register local AI services (AppChat powered by on-device SLM)
+ RegisterLocalAI(builder);
+
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
builder.Logging.AddDebug();
@@ -133,4 +141,39 @@ private static void LogException(string source, Exception? ex)
}
catch { /* Don't throw in exception handler */ }
}
+
+ ///
+ /// Register the local AI IChatClient pipeline and DirectLocalChatService.
+ /// On Apple platforms, uses AppleIntelligenceChatClient + NLEmbeddingGenerator.
+ /// On other platforms, AppChat is unavailable (no SLM provider yet).
+ ///
+ private static void RegisterLocalAI(MauiAppBuilder builder)
+ {
+#if MACCATALYST || IOS
+ // Apple Intelligence provides on-device IChatClient + IEmbeddingGenerator
+ #pragma warning disable MAUIAI0001 // Apple Intelligence is experimental
+ builder.Services.AddSingleton();
+
+ #pragma warning disable MEAI001 // EmbeddingToolReductionStrategy is experimental
+ builder.Services.AddKeyedSingleton("local", (sp, _) =>
+ {
+ var appleClient = sp.GetRequiredService();
+ #pragma warning restore MAUIAI0001
+ var loggerFactory = sp.GetRequiredService();
+
+ return appleClient
+ .AsBuilder()
+ .UseLogging(loggerFactory)
+ .ConfigureOptions(o =>
+ {
+ o.MaxOutputTokens = 350;
+ o.AllowMultipleToolCalls = false;
+ })
+ .Build();
+ });
+ #pragma warning restore MEAI001
+
+ builder.Services.AddSingleton();
+#endif
+ }
}
diff --git a/PolyPilot/PolyPilot.csproj b/PolyPilot/PolyPilot.csproj
index 0f69a45f..ae4f3861 100644
--- a/PolyPilot/PolyPilot.csproj
+++ b/PolyPilot/PolyPilot.csproj
@@ -74,7 +74,9 @@
-
+
+
+
diff --git a/PolyPilot/Services/AI/AppChatTools.cs b/PolyPilot/Services/AI/AppChatTools.cs
new file mode 100644
index 00000000..806271e8
--- /dev/null
+++ b/PolyPilot/Services/AI/AppChatTools.cs
@@ -0,0 +1,105 @@
+using System.Text.Json;
+using Microsoft.Extensions.AI;
+
+namespace PolyPilot.Services.AI;
+
+///
+/// Tools that the local AppChat agent can invoke to query and navigate app state.
+/// Each method returns a JSON string summary for the SLM.
+///
+public static class AppChatTools
+{
+ ///
+ /// Creates the set of AIFunction tools for AppChat sessions.
+ /// The CopilotService instance is captured by closure.
+ ///
+ public static IList Create(CopilotService service)
+ {
+ return
+ [
+ AIFunctionFactory.Create(
+ () => GetSessionList(service),
+ nameof(GetSessionList),
+ "Lists all active Copilot sessions with their name, model, message count, processing status, working directory, and git branch."),
+
+ AIFunctionFactory.Create(
+ () => GetRecentErrors(service),
+ nameof(GetRecentErrors),
+ "Returns the most recent error messages from all sessions, if any."),
+
+ AIFunctionFactory.Create(
+ (string sessionName) => SwitchToSession(service, sessionName),
+ nameof(SwitchToSession),
+ "Switches the UI to show the specified session. Use the exact session name from GetSessionList."),
+
+ AIFunctionFactory.Create(
+ (string query) => SearchSessionHistory(service, query),
+ nameof(SearchSessionHistory),
+ "Searches all session chat histories for messages containing the query text. Returns matching messages with session name, role, content snippet, and timestamp."),
+ ];
+ }
+
+ private static string GetSessionList(CopilotService service)
+ {
+ var sessions = service.GetAllSessions().Select(s => new
+ {
+ s.Name,
+ s.Model,
+ s.MessageCount,
+ s.IsProcessing,
+ HasQueue = s.MessageQueue.Count > 0,
+ s.WorkingDirectory,
+ s.GitBranch
+ });
+ return JsonSerializer.Serialize(sessions);
+ }
+
+ private static string GetRecentErrors(CopilotService service)
+ {
+ var errors = service.GetAllSessions()
+ .SelectMany(s => s.History
+ .Where(m => m?.MessageType == Models.ChatMessageType.Error)
+ .TakeLast(3)
+ .Select(m => new { Session = s.Name, m.Content, m.Timestamp }))
+ .OrderByDescending(e => e.Timestamp)
+ .Take(10);
+ return JsonSerializer.Serialize(errors);
+ }
+
+ private static string SwitchToSession(CopilotService service, string sessionName)
+ {
+ var session = service.GetAllSessions().FirstOrDefault(s =>
+ s.Name.Equals(sessionName, StringComparison.OrdinalIgnoreCase));
+ if (session == null)
+ return JsonSerializer.Serialize(new { Error = $"Session '{sessionName}' not found." });
+
+ service.SwitchSession(session.Name);
+ session.LastReadMessageCount = session.History.Count;
+ service.SaveUiState("/", session.Name, expandedSession: session.Name);
+ return JsonSerializer.Serialize(new { Success = true, SwitchedTo = session.Name });
+ }
+
+ private static string SearchSessionHistory(CopilotService service, string query)
+ {
+ if (string.IsNullOrWhiteSpace(query))
+ return JsonSerializer.Serialize(new { Error = "Query cannot be empty." });
+
+ var results = service.GetAllSessions()
+ .SelectMany(s => s.History
+ .Where(m => m?.Content != null &&
+ m.Content.Contains(query, StringComparison.OrdinalIgnoreCase))
+ .TakeLast(5)
+ .Select(m => new
+ {
+ Session = s.Name,
+ m.Role,
+ Content = m.Content.Length > 200
+ ? m.Content[..200] + "..."
+ : m.Content,
+ m.Timestamp
+ }))
+ .OrderByDescending(r => r.Timestamp)
+ .Take(15);
+ return JsonSerializer.Serialize(results);
+ }
+}
diff --git a/PolyPilot/Services/AI/DirectLocalChatService.cs b/PolyPilot/Services/AI/DirectLocalChatService.cs
new file mode 100644
index 00000000..d7699f07
--- /dev/null
+++ b/PolyPilot/Services/AI/DirectLocalChatService.cs
@@ -0,0 +1,131 @@
+using System.Collections.Concurrent;
+using System.Text;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace PolyPilot.Services.AI;
+
+///
+/// Thin adapter over ChatClientAgent for local AppChat sessions.
+/// Each session gets its own AgentSession for conversation history.
+///
+public class DirectLocalChatService
+{
+ private readonly IChatClient _chatClient;
+ private readonly CopilotService _copilotService;
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary _sessions = new();
+ private ChatClientAgent? _agent;
+
+ private const string SystemPrompt = """
+ You are PolyPilot Assistant, a helpful on-device AI that answers questions about the user's
+ active Copilot sessions, queued messages, and session organization. You have access to tools
+ that let you read app state. Keep responses concise — you are running on a local model with
+ limited context. If you don't know something or a tool doesn't return useful data, say so
+ briefly rather than guessing.
+ """;
+
+ public DirectLocalChatService(
+ [FromKeyedServices("local")] IChatClient chatClient,
+ CopilotService copilotService,
+ ILogger logger)
+ {
+ _chatClient = chatClient;
+ _copilotService = copilotService;
+ _logger = logger;
+ }
+
+ private ChatClientAgent GetOrCreateAgent()
+ {
+ if (_agent is not null) return _agent;
+
+ var tools = AppChatTools.Create(_copilotService);
+ _agent = new ChatClientAgent(
+ _chatClient,
+ instructions: SystemPrompt,
+ name: "PolyPilotAssistant",
+ tools: tools.Cast().ToList());
+ return _agent;
+ }
+
+ private async Task GetOrCreateSessionAsync(string sessionName)
+ {
+ if (_sessions.TryGetValue(sessionName, out var existing))
+ return existing;
+
+ var agent = GetOrCreateAgent();
+ var session = await agent.CreateSessionAsync();
+ _sessions[sessionName] = session;
+ return session;
+ }
+
+ ///
+ /// Send a prompt to the local model and stream the response back via callbacks.
+ ///
+ public async Task SendPromptStreamingAsync(
+ string sessionName,
+ string prompt,
+ Action onDelta,
+ Action onComplete,
+ Action? onError = null,
+ Action? onToolStart = null,
+ Action? onToolEnd = null,
+ CancellationToken cancellationToken = default)
+ {
+ var agent = GetOrCreateAgent();
+ var session = await GetOrCreateSessionAsync(sessionName);
+
+ var fullResponse = new StringBuilder();
+
+ try
+ {
+ await foreach (var update in agent.RunStreamingAsync(
+ prompt,
+ session,
+ cancellationToken: cancellationToken))
+ {
+ // Surface tool calls to the UI
+ foreach (var content in update.Contents)
+ {
+ if (content is FunctionCallContent fcc)
+ {
+ var argsJson = fcc.Arguments is { Count: > 0 }
+ ? System.Text.Json.JsonSerializer.Serialize(fcc.Arguments)
+ : null;
+ onToolStart?.Invoke(fcc.Name, fcc.CallId, argsJson);
+ }
+ else if (content is FunctionResultContent frc)
+ onToolEnd?.Invoke(frc.CallId, frc.Result?.ToString());
+ }
+
+ var text = update.Text;
+ if (!string.IsNullOrEmpty(text))
+ {
+ fullResponse.Append(text);
+ onDelta(text);
+ }
+ }
+
+ onComplete(fullResponse.ToString());
+ }
+ catch (OperationCanceledException)
+ {
+ onComplete(fullResponse.ToString());
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error in AppChat session '{SessionName}'", sessionName);
+ onError?.Invoke(ex.Message);
+ }
+ }
+
+ ///
+ /// Remove the session for a closed AppChat.
+ ///
+ public void RemoveSession(string sessionName)
+ {
+ _sessions.TryRemove(sessionName, out _);
+ }
+}