From 1d7995b5f9a38b45392949f7e87e421d458da792 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Tue, 24 Feb 2026 15:25:43 +0200 Subject: [PATCH 01/11] Phase 1: AppChat as standalone popover (revert session integration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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> --- PolyPilot.Tests/PolyPilot.Tests.csproj | 5 + PolyPilot/Components/AppChatPopover.razor | 129 ++++++++++++ PolyPilot/Components/AppChatPopover.razor.css | 132 ++++++++++++ PolyPilot/Components/Layout/MainLayout.razor | 14 +- .../Components/Layout/SessionSidebar.razor | 2 + .../Layout/SessionSidebar.razor.css | 7 + PolyPilot/MauiProgram.cs | 45 ++++ PolyPilot/PolyPilot.csproj | 2 + PolyPilot/Services/AI/AppChatTools.cs | 103 +++++++++ .../Services/AI/DirectLocalChatService.cs | 115 ++++++++++ .../AI/NonFunctionInvokingChatClient.cs | 198 ++++++++++++++++++ 11 files changed, 749 insertions(+), 3 deletions(-) create mode 100644 PolyPilot/Components/AppChatPopover.razor create mode 100644 PolyPilot/Components/AppChatPopover.razor.css create mode 100644 PolyPilot/Services/AI/AppChatTools.cs create mode 100644 PolyPilot/Services/AI/DirectLocalChatService.cs create mode 100644 PolyPilot/Services/AI/NonFunctionInvokingChatClient.cs diff --git a/PolyPilot.Tests/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj index b123781a..9fef6d9c 100644 --- a/PolyPilot.Tests/PolyPilot.Tests.csproj +++ b/PolyPilot.Tests/PolyPilot.Tests.csproj @@ -12,6 +12,8 @@ + + @@ -66,6 +68,9 @@ + + + diff --git a/PolyPilot/Components/AppChatPopover.razor b/PolyPilot/Components/AppChatPopover.razor new file mode 100644 index 00000000..40412d96 --- /dev/null +++ b/PolyPilot/Components/AppChatPopover.razor @@ -0,0 +1,129 @@ +@using PolyPilot.Models +@using PolyPilot.Services +@using PolyPilot.Services.AI +@inject DirectLocalChatService LocalChat +@inject IJSRuntime JS + +@if (IsVisible) +{ +
+
+ ✨ App Chat +
+ @if (messages.Count > 0) + { + + } + +
+
+ + @if (isExpanded) + { +
+ +
+ +
+ + +
+ } +
+} + +@code { + [Parameter] public bool IsVisible { get; set; } + [Parameter] public EventCallback OnClose { get; set; } + + private List messages = new(); + private string streamingContent = ""; + private bool isProcessing; + private bool isExpanded = true; + private string sessionName = "appchat-" + Guid.NewGuid().ToString("N")[..8]; + private CancellationTokenSource? _cts; + + private void ToggleExpand() => isExpanded = !isExpanded; + + private async Task Close() + { + _cts?.Cancel(); + await OnClose.InvokeAsync(); + } + + private void ClearChat() + { + _cts?.Cancel(); + messages.Clear(); + streamingContent = ""; + 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 => + { + streamingContent += delta; + InvokeAsync(StateHasChanged); + }, + onComplete: full => + { + messages.Add(ChatMessage.AssistantMessage(full)); + streamingContent = ""; + isProcessing = false; + InvokeAsync(StateHasChanged); + }, + onError: error => + { + messages.Add(ChatMessage.ErrorMessage(error)); + streamingContent = ""; + isProcessing = false; + 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..a8feeccd --- /dev/null +++ b/PolyPilot/Components/AppChatPopover.razor.css @@ -0,0 +1,132 @@ +.appchat-popover { + position: fixed; + top: 50px; + left: 16px; + width: 380px; + max-height: 560px; + 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; + transition: max-height 0.2s ease; +} + +.appchat-popover.collapsed { + max-height: 44px; +} + +.appchat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: var(--bg-secondary, #2a2a3e); + cursor: pointer; + user-select: none; + 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, +.appchat-close-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, +.appchat-close-btn:hover { + background: var(--bg-hover, rgba(255, 255, 255, 0.1)); + color: var(--text-primary, #e0e0e0); +} + +.appchat-body { + flex: 1; + overflow-y: auto; + min-height: 200px; + max-height: 440px; + padding: 8px; +} + +.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 6a7d47e8..e4441497 100644 --- a/PolyPilot/Components/Layout/MainLayout.razor +++ b/PolyPilot/Components/Layout/MainLayout.razor @@ -4,12 +4,12 @@
- +
@@ -20,14 +20,17 @@
- +
+ +
@code { private bool flyoutOpen; + private bool appChatOpen; private int fontSize = 20; [Inject] private IJSRuntime JS { get; set; } = default!; @@ -121,4 +124,9 @@ { flyoutOpen = false; } + + private void ToggleAppChat() + { + appChatOpen = !appChatOpen; + } } diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor b/PolyPilot/Components/Layout/SessionSidebar.razor index bb574737..3350f573 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor +++ b/PolyPilot/Components/Layout/SessionSidebar.razor @@ -55,6 +55,7 @@ else +

@@ -169,6 +170,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; } diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor.css b/PolyPilot/Components/Layout/SessionSidebar.razor.css index 175abc8b..35d1dd70 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor.css +++ b/PolyPilot/Components/Layout/SessionSidebar.razor.css @@ -1082,6 +1082,13 @@ color: var(--accent-primary); } +.appchat-toggle { + font-size: 12px; + border: none; + background: none; + cursor: pointer; +} + .rename-input { width: 100%; padding: 0.2rem 0.4rem; diff --git a/PolyPilot/MauiProgram.cs b/PolyPilot/MauiProgram.cs index 2274eb1a..d9790fb8 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 #if MACCATALYST using Microsoft.Maui.LifecycleEvents; using UIKit; @@ -106,6 +111,9 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Register local AI services (AppChat powered by on-device SLM) + RegisterLocalAI(builder); + #if DEBUG builder.Services.AddBlazorWebViewDeveloperTools(); builder.Logging.AddDebug(); @@ -128,4 +136,41 @@ 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) + // Workaround for https://github.com/dotnet/extensions/issues/7204 + .Use(cc => new NonFunctionInvokingChatClient(cc, loggerFactory, sp)) + .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 e5564f32..3931b2cb 100644 --- a/PolyPilot/PolyPilot.csproj +++ b/PolyPilot/PolyPilot.csproj @@ -73,6 +73,8 @@ + + diff --git a/PolyPilot/Services/AI/AppChatTools.cs b/PolyPilot/Services/AI/AppChatTools.cs new file mode 100644 index 00000000..22d56ae0 --- /dev/null +++ b/PolyPilot/Services/AI/AppChatTools.cs @@ -0,0 +1,103 @@ +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.SetActiveSession(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..9396e78a --- /dev/null +++ b/PolyPilot/Services/AI/DirectLocalChatService.cs @@ -0,0 +1,115 @@ +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, + 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)) + { + 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 _); + } +} diff --git a/PolyPilot/Services/AI/NonFunctionInvokingChatClient.cs b/PolyPilot/Services/AI/NonFunctionInvokingChatClient.cs new file mode 100644 index 00000000..9991b08a --- /dev/null +++ b/PolyPilot/Services/AI/NonFunctionInvokingChatClient.cs @@ -0,0 +1,198 @@ +// Adapted from https://github.com/dotnet/maui/blob/main/src/AI/samples/Essentials.AI.Sample/AI/NonFunctionInvokingChatClient.cs +// Workaround for https://github.com/dotnet/extensions/issues/7204 +// Agent Framework's ChatClientAgent double-invokes tool calls when FunctionInvokingChatClient is in the pipeline. + +using System.Runtime.CompilerServices; +using System.Text.Json; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace PolyPilot.Services.AI; + +/// +/// A chat client wrapper that prevents Agent Framework from adding its own function invocation layer. +/// +/// +/// +/// Some chat clients handle tool invocation internally - when tools are registered, the underlying +/// service invokes them automatically and returns the results. However, Agent Framework's +/// ChatClientAgent also tries to invoke tools when it sees +/// in the response, causing double invocation. +/// +/// +/// This wrapper solves the problem by: +/// +/// The inner handler converts and +/// to internal marker types that doesn't recognize +/// We wrap the handler with a real , satisfying +/// Agent Framework's GetService<FunctionInvokingChatClient>() check so it won't create another +/// The outer layer unwraps the marker types back to the original content types for the caller +/// +/// +/// +public sealed partial class NonFunctionInvokingChatClient : DelegatingChatClient +{ + private readonly ILogger _logger; + + public NonFunctionInvokingChatClient( + IChatClient innerClient, + ILoggerFactory? loggerFactory = null, + IServiceProvider? serviceProvider = null) + : base(CreateInnerClient(innerClient, loggerFactory, serviceProvider)) + { + _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; + } + + private static FunctionInvokingChatClient CreateInnerClient( + IChatClient innerClient, + ILoggerFactory? loggerFactory, + IServiceProvider? serviceProvider) + { + ArgumentNullException.ThrowIfNull(innerClient); + var handler = new ToolCallPassThroughHandler(innerClient); + return new FunctionInvokingChatClient(handler, loggerFactory, serviceProvider); + } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + foreach (var message in response.Messages) + { + Unwrap(message.Contents); + } + return response; + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + Unwrap(update.Contents); + yield return update; + } + } + + private void Unwrap(IList contents) + { + for (var i = 0; i < contents.Count; i++) + { + if (contents[i] is ServerFunctionCallContent serverFcc) + { + var fcc = serverFcc.FunctionCallContent; + LogFunctionInvoking(fcc.Name, fcc.CallId, fcc.Arguments); + contents[i] = fcc; + } + else if (contents[i] is ServerFunctionResultContent serverFrc) + { + var frc = serverFrc.FunctionResultContent; + LogFunctionInvocationCompleted(frc.CallId, frc.Result); + contents[i] = frc; + } + } + } + + private void LogFunctionInvoking(string functionName, string callId, IDictionary? arguments) + { + if (_logger.IsEnabled(LogLevel.Trace) && arguments is not null) + { + var argsJson = JsonSerializer.Serialize(arguments, AIJsonUtilities.DefaultOptions); + LogToolInvokedSensitive(functionName, callId, argsJson); + } + else if (_logger.IsEnabled(LogLevel.Debug)) + { + LogToolInvoked(functionName, callId); + } + } + + private void LogFunctionInvocationCompleted(string callId, object? result) + { + if (_logger.IsEnabled(LogLevel.Trace) && result is not null) + { + var resultJson = result is string s ? s : JsonSerializer.Serialize(result, AIJsonUtilities.DefaultOptions); + LogToolInvocationCompletedSensitive(callId, resultJson); + } + else if (_logger.IsEnabled(LogLevel.Debug)) + { + LogToolInvocationCompleted(callId); + } + } + + [LoggerMessage(LogLevel.Debug, "Received tool call: {ToolName} (ID: {ToolCallId})")] + private partial void LogToolInvoked(string toolName, string toolCallId); + + [LoggerMessage(LogLevel.Trace, "Received tool call: {ToolName} (ID: {ToolCallId}) with arguments: {Arguments}")] + private partial void LogToolInvokedSensitive(string toolName, string toolCallId, string arguments); + + [LoggerMessage(LogLevel.Debug, "Received tool result for call ID: {ToolCallId}")] + private partial void LogToolInvocationCompleted(string toolCallId); + + [LoggerMessage(LogLevel.Trace, "Received tool result for call ID: {ToolCallId}: {Result}")] + private partial void LogToolInvocationCompletedSensitive(string toolCallId, string result); + + /// + /// Handler that wraps the inner client and converts tool call/result content to server-handled types. + /// + private sealed class ToolCallPassThroughHandler(IChatClient innerClient) : DelegatingChatClient(innerClient) + { + public override async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + foreach (var message in response.Messages) + { + Wrap(message.Contents); + } + return response; + } + + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + Wrap(update.Contents); + yield return update; + } + } + + private static void Wrap(IList contents) + { + for (var i = 0; i < contents.Count; i++) + { + if (contents[i] is FunctionCallContent fcc) + contents[i] = new ServerFunctionCallContent(fcc); + else if (contents[i] is FunctionResultContent frc) + contents[i] = new ServerFunctionResultContent(frc); + } + } + } + + /// + /// Marker type for function calls that were already handled by the inner client. + /// + private sealed class ServerFunctionCallContent(FunctionCallContent functionCallContent) : AIContent + { + public FunctionCallContent FunctionCallContent { get; } = functionCallContent; + } + + /// + /// Marker type for function results that were already produced by the inner client. + /// + private sealed class ServerFunctionResultContent(FunctionResultContent functionResultContent) : AIContent + { + public FunctionResultContent FunctionResultContent { get; } = functionResultContent; + } +} From c9a9c635b114a73969a49e37a44abd05e0d57913 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Tue, 24 Feb 2026 20:26:51 +0200 Subject: [PATCH 02/11] Fix SwitchToSession tool: use SwitchSession + SaveUiState for proper 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> --- PolyPilot/Services/AI/AppChatTools.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/PolyPilot/Services/AI/AppChatTools.cs b/PolyPilot/Services/AI/AppChatTools.cs index 22d56ae0..806271e8 100644 --- a/PolyPilot/Services/AI/AppChatTools.cs +++ b/PolyPilot/Services/AI/AppChatTools.cs @@ -73,7 +73,9 @@ private static string SwitchToSession(CopilotService service, string sessionName if (session == null) return JsonSerializer.Serialize(new { Error = $"Session '{sessionName}' not found." }); - service.SetActiveSession(session.Name); + 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 }); } From 6b3f7a50e1d63b44301eff610cb8457ecf8ea86f Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Tue, 24 Feb 2026 23:14:10 +0200 Subject: [PATCH 03/11] Fix session switch from AppChat: expand session even from grid view 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> --- PolyPilot/Components/Pages/Dashboard.razor | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index 64e487c0..de1c6f0f 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -921,8 +921,8 @@ _needsScrollToBottom = true; CopilotService.SaveUiState("/dashboard", activeSession: active, expandedSession: active, expandedGrid: !isCompactGrid); } - // 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; From c04f5414fe3163747f0bc2dacc7a1aecbde74874 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Tue, 24 Feb 2026 23:19:56 +0200 Subject: [PATCH 04/11] Fix expanded session switch + remove popover collapse - 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> --- PolyPilot/Components/AppChatPopover.razor | 60 +++++++------------ PolyPilot/Components/AppChatPopover.razor.css | 13 +--- PolyPilot/Components/Layout/MainLayout.razor | 2 +- PolyPilot/Components/Pages/Dashboard.razor | 1 + 4 files changed, 27 insertions(+), 49 deletions(-) diff --git a/PolyPilot/Components/AppChatPopover.razor b/PolyPilot/Components/AppChatPopover.razor index 40412d96..af9c05db 100644 --- a/PolyPilot/Components/AppChatPopover.razor +++ b/PolyPilot/Components/AppChatPopover.razor @@ -6,64 +6,50 @@ @if (IsVisible) { -
-
+
+
✨ App Chat
@if (messages.Count > 0) { - + } -
- @if (isExpanded) - { -
- -
+
+ +
-
- - -
- } +
+ + +
} @code { [Parameter] public bool IsVisible { get; set; } - [Parameter] public EventCallback OnClose { get; set; } private List messages = new(); private string streamingContent = ""; private bool isProcessing; - private bool isExpanded = true; private string sessionName = "appchat-" + Guid.NewGuid().ToString("N")[..8]; private CancellationTokenSource? _cts; - private void ToggleExpand() => isExpanded = !isExpanded; - - private async Task Close() - { - _cts?.Cancel(); - await OnClose.InvokeAsync(); - } - private void ClearChat() { _cts?.Cancel(); diff --git a/PolyPilot/Components/AppChatPopover.razor.css b/PolyPilot/Components/AppChatPopover.razor.css index a8feeccd..4c8d83cf 100644 --- a/PolyPilot/Components/AppChatPopover.razor.css +++ b/PolyPilot/Components/AppChatPopover.razor.css @@ -12,11 +12,6 @@ display: flex; flex-direction: column; overflow: hidden; - transition: max-height 0.2s ease; -} - -.appchat-popover.collapsed { - max-height: 44px; } .appchat-header { @@ -25,8 +20,6 @@ justify-content: space-between; padding: 10px 14px; background: var(--bg-secondary, #2a2a3e); - cursor: pointer; - user-select: none; border-bottom: 1px solid var(--border-color, #444); flex-shrink: 0; } @@ -42,8 +35,7 @@ gap: 4px; } -.appchat-clear-btn, -.appchat-close-btn { +.appchat-clear-btn { background: none; border: none; color: var(--text-secondary, #999); @@ -54,8 +46,7 @@ line-height: 1; } -.appchat-clear-btn:hover, -.appchat-close-btn:hover { +.appchat-clear-btn:hover { background: var(--bg-hover, rgba(255, 255, 255, 0.1)); color: var(--text-primary, #e0e0e0); } diff --git a/PolyPilot/Components/Layout/MainLayout.razor b/PolyPilot/Components/Layout/MainLayout.razor index e4441497..14f2d984 100644 --- a/PolyPilot/Components/Layout/MainLayout.razor +++ b/PolyPilot/Components/Layout/MainLayout.razor @@ -25,7 +25,7 @@ - +
@code { diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index de1c6f0f..755a7f36 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -927,6 +927,7 @@ _lastActiveSession = active; expandedSession = active; _needsScrollToBottom = true; + _ = InvokeAsync(() => JS.InvokeVoidAsync("switchKeepAliveSlot", $"slot-{active.Replace(" ", "-")}", active)); } if (_lastActiveSession == null && active != null) From 22a735324e28f0d2005c8724c527c08aa85536eb Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Tue, 24 Feb 2026 23:28:38 +0200 Subject: [PATCH 05/11] Fix AppChat: strip tool content from history + switchKeepAliveSlot on 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> --- PolyPilot/Components/Pages/Dashboard.razor | 1 + .../AI/NonFunctionInvokingChatClient.cs | 35 +++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index 755a7f36..0dd4791e 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -920,6 +920,7 @@ _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, tool switch) else if (active != null && expandedSession != active && sessions.Any(s => s.Name == active)) diff --git a/PolyPilot/Services/AI/NonFunctionInvokingChatClient.cs b/PolyPilot/Services/AI/NonFunctionInvokingChatClient.cs index 9991b08a..8786ebee 100644 --- a/PolyPilot/Services/AI/NonFunctionInvokingChatClient.cs +++ b/PolyPilot/Services/AI/NonFunctionInvokingChatClient.cs @@ -140,6 +140,8 @@ private void LogFunctionInvocationCompleted(string callId, object? result) /// /// Handler that wraps the inner client and converts tool call/result content to server-handled types. + /// Also strips FunctionCallContent/FunctionResultContent from input messages since Apple Intelligence + /// doesn't support them in conversation history (it handles tool calls internally). /// private sealed class ToolCallPassThroughHandler(IChatClient innerClient) : DelegatingChatClient(innerClient) { @@ -148,7 +150,7 @@ public override async Task GetResponseAsync( ChatOptions? options = null, CancellationToken cancellationToken = default) { - var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + var response = await base.GetResponseAsync(StripToolContent(messages), options, cancellationToken).ConfigureAwait(false); foreach (var message in response.Messages) { Wrap(message.Contents); @@ -161,13 +163,42 @@ public override async IAsyncEnumerable GetStreamingResponseA ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + await foreach (var update in base.GetStreamingResponseAsync(StripToolContent(messages), options, cancellationToken).ConfigureAwait(false)) { Wrap(update.Contents); yield return update; } } + /// + /// Remove FunctionCallContent/FunctionResultContent from history messages. + /// The AgentSession stores these from previous turns, but Apple Intelligence + /// can't accept them as input — it handles tool calls internally. + /// + private static IEnumerable StripToolContent(IEnumerable messages) + { + foreach (var msg in messages) + { + if (!msg.Contents.Any(c => c is FunctionCallContent or FunctionResultContent)) + { + yield return msg; + continue; + } + + // Filter out tool content, keep everything else + var filtered = msg.Contents + .Where(c => c is not FunctionCallContent and not FunctionResultContent) + .ToList(); + + if (filtered.Count > 0) + { + var clean = new ChatMessage(msg.Role, filtered); + yield return clean; + } + // Skip messages that were entirely tool content + } + } + private static void Wrap(IList contents) { for (var i = 0; i < contents.Count; i++) From c09d1a172675d2d270bbd2cab48d6308adc8e77d Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Tue, 24 Feb 2026 23:33:11 +0200 Subject: [PATCH 06/11] Add tool call activity display to AppChat popover - 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> --- PolyPilot/Components/AppChatPopover.razor | 39 +++++++++++++++++++ .../Services/AI/DirectLocalChatService.cs | 11 ++++++ 2 files changed, 50 insertions(+) diff --git a/PolyPilot/Components/AppChatPopover.razor b/PolyPilot/Components/AppChatPopover.razor index af9c05db..29b8159c 100644 --- a/PolyPilot/Components/AppChatPopover.razor +++ b/PolyPilot/Components/AppChatPopover.razor @@ -20,6 +20,8 @@
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; @@ -54,7 +58,9 @@ { _cts?.Cancel(); messages.Clear(); + toolActivities.Clear(); streamingContent = ""; + currentToolName = ""; isProcessing = false; LocalChat.RemoveSession(sessionName); sessionName = "appchat-" + Guid.NewGuid().ToString("N")[..8]; @@ -86,6 +92,11 @@ prompt, onDelta: delta => { + // Clear tool state when text starts streaming + if (currentToolName != "") + { + currentToolName = ""; + } streamingContent += delta; InvokeAsync(StateHasChanged); }, @@ -93,6 +104,8 @@ { messages.Add(ChatMessage.AssistantMessage(full)); streamingContent = ""; + currentToolName = ""; + toolActivities.Clear(); isProcessing = false; InvokeAsync(StateHasChanged); }, @@ -100,9 +113,35 @@ { messages.Add(ChatMessage.ErrorMessage(error)); streamingContent = ""; + currentToolName = ""; + toolActivities.Clear(); isProcessing = false; InvokeAsync(StateHasChanged); }, + onToolStart: (name, callId) => + { + currentToolName = name; + toolActivities.Add(new ToolActivity + { + Name = name, + CallId = callId, + 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?.Length > 200 ? result[..200] + "..." : result; + } + currentToolName = ""; + InvokeAsync(StateHasChanged); + }, cancellationToken: _cts.Token); } diff --git a/PolyPilot/Services/AI/DirectLocalChatService.cs b/PolyPilot/Services/AI/DirectLocalChatService.cs index 9396e78a..ce3eefcc 100644 --- a/PolyPilot/Services/AI/DirectLocalChatService.cs +++ b/PolyPilot/Services/AI/DirectLocalChatService.cs @@ -70,6 +70,8 @@ public async Task SendPromptStreamingAsync( Action onDelta, Action onComplete, Action? onError = null, + Action? onToolStart = null, + Action? onToolEnd = null, CancellationToken cancellationToken = default) { var agent = GetOrCreateAgent(); @@ -84,6 +86,15 @@ public async Task SendPromptStreamingAsync( session, cancellationToken: cancellationToken)) { + // Surface tool calls to the UI + foreach (var content in update.Contents) + { + if (content is FunctionCallContent fcc) + onToolStart?.Invoke(fcc.Name, fcc.CallId); + else if (content is FunctionResultContent frc) + onToolEnd?.Invoke(frc.CallId, frc.Result?.ToString()); + } + var text = update.Text; if (!string.IsNullOrEmpty(text)) { From ca22dde6be5165a3baa934fd789123068755444b Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Tue, 24 Feb 2026 23:41:35 +0200 Subject: [PATCH 07/11] Add tool call messages to chat + fix code block wrapping - 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> --- PolyPilot/Components/AppChatPopover.razor | 9 +++++++++ PolyPilot/Components/AppChatPopover.razor.css | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/PolyPilot/Components/AppChatPopover.razor b/PolyPilot/Components/AppChatPopover.razor index 29b8159c..c1185733 100644 --- a/PolyPilot/Components/AppChatPopover.razor +++ b/PolyPilot/Components/AppChatPopover.razor @@ -121,6 +121,7 @@ onToolStart: (name, callId) => { currentToolName = name; + messages.Add(ChatMessage.ToolCallMessage(name, callId)); toolActivities.Add(new ToolActivity { Name = name, @@ -139,6 +140,14 @@ activity.CompletedAt = DateTime.Now; activity.Result = result?.Length > 200 ? result[..200] + "..." : result; } + // Mark the tool call message as complete + var toolMsg = messages.LastOrDefault(m => m.ToolCallId == callId); + if (toolMsg != null) + { + toolMsg.IsComplete = true; + toolMsg.IsSuccess = true; + toolMsg.Content = result?.Length > 300 ? result[..300] + "..." : result ?? ""; + } currentToolName = ""; InvokeAsync(StateHasChanged); }, diff --git a/PolyPilot/Components/AppChatPopover.razor.css b/PolyPilot/Components/AppChatPopover.razor.css index 4c8d83cf..42fa3f8b 100644 --- a/PolyPilot/Components/AppChatPopover.razor.css +++ b/PolyPilot/Components/AppChatPopover.razor.css @@ -54,11 +54,25 @@ .appchat-body { flex: 1; overflow-y: auto; + overflow-x: hidden; min-height: 200px; max-height: 440px; 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; From 094a95ea47f28d72821be6dbcb6c79770d3ad0c0 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Wed, 25 Feb 2026 00:01:50 +0200 Subject: [PATCH 08/11] Pass tool args and full result to chat messages - 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> --- PolyPilot/Components/AppChatPopover.razor | 11 ++++++----- PolyPilot/Services/AI/DirectLocalChatService.cs | 9 +++++++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/PolyPilot/Components/AppChatPopover.razor b/PolyPilot/Components/AppChatPopover.razor index c1185733..9753fafb 100644 --- a/PolyPilot/Components/AppChatPopover.razor +++ b/PolyPilot/Components/AppChatPopover.razor @@ -118,14 +118,15 @@ isProcessing = false; InvokeAsync(StateHasChanged); }, - onToolStart: (name, callId) => + onToolStart: (name, callId, argsJson) => { currentToolName = name; - messages.Add(ChatMessage.ToolCallMessage(name, callId)); + messages.Add(ChatMessage.ToolCallMessage(name, callId, argsJson)); toolActivities.Add(new ToolActivity { Name = name, CallId = callId, + Input = argsJson, StartedAt = DateTime.Now }); InvokeAsync(StateHasChanged); @@ -138,15 +139,15 @@ activity.IsComplete = true; activity.IsSuccess = true; activity.CompletedAt = DateTime.Now; - activity.Result = result?.Length > 200 ? result[..200] + "..." : result; + activity.Result = result; } - // Mark the tool call message as complete var toolMsg = messages.LastOrDefault(m => m.ToolCallId == callId); if (toolMsg != null) { toolMsg.IsComplete = true; toolMsg.IsSuccess = true; - toolMsg.Content = result?.Length > 300 ? result[..300] + "..." : result ?? ""; + toolMsg.Content = result ?? ""; + toolMsg.IsCollapsed = true; } currentToolName = ""; InvokeAsync(StateHasChanged); diff --git a/PolyPilot/Services/AI/DirectLocalChatService.cs b/PolyPilot/Services/AI/DirectLocalChatService.cs index ce3eefcc..d7699f07 100644 --- a/PolyPilot/Services/AI/DirectLocalChatService.cs +++ b/PolyPilot/Services/AI/DirectLocalChatService.cs @@ -70,7 +70,7 @@ public async Task SendPromptStreamingAsync( Action onDelta, Action onComplete, Action? onError = null, - Action? onToolStart = null, + Action? onToolStart = null, Action? onToolEnd = null, CancellationToken cancellationToken = default) { @@ -90,7 +90,12 @@ public async Task SendPromptStreamingAsync( foreach (var content in update.Contents) { if (content is FunctionCallContent fcc) - onToolStart?.Invoke(fcc.Name, fcc.CallId); + { + 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()); } From 43cb90aaf3ac10dc3bb0574fa2e067925efb1967 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Wed, 25 Feb 2026 00:05:04 +0200 Subject: [PATCH 09/11] Use full tool rendering in AppChat (not compact) 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> --- PolyPilot/Components/AppChatPopover.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PolyPilot/Components/AppChatPopover.razor b/PolyPilot/Components/AppChatPopover.razor index 9753fafb..0d5939f7 100644 --- a/PolyPilot/Components/AppChatPopover.razor +++ b/PolyPilot/Components/AppChatPopover.razor @@ -23,7 +23,7 @@ CurrentToolName="@currentToolName" ToolActivities="@toolActivities" IsProcessing="@isProcessing" - Compact="true" + Compact="false" Layout="ChatLayout.BothLeft" Style="ChatStyle.Minimal" />
From c6fe27edd44c7979b30bae1d1d2701cbd9d21f55 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Tue, 3 Mar 2026 19:51:38 +0200 Subject: [PATCH 10/11] Remove NonFunctionInvokingChatClient workaround 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> --- PolyPilot.Tests/PolyPilot.Tests.csproj | 1 - PolyPilot/MauiProgram.cs | 2 - .../AI/NonFunctionInvokingChatClient.cs | 229 ------------------ 3 files changed, 232 deletions(-) delete mode 100644 PolyPilot/Services/AI/NonFunctionInvokingChatClient.cs diff --git a/PolyPilot.Tests/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj index 37b7bbc9..4bd092d5 100644 --- a/PolyPilot.Tests/PolyPilot.Tests.csproj +++ b/PolyPilot.Tests/PolyPilot.Tests.csproj @@ -78,7 +78,6 @@ - diff --git a/PolyPilot/MauiProgram.cs b/PolyPilot/MauiProgram.cs index 7a1b3bae..39aaf482 100644 --- a/PolyPilot/MauiProgram.cs +++ b/PolyPilot/MauiProgram.cs @@ -163,8 +163,6 @@ private static void RegisterLocalAI(MauiAppBuilder builder) return appleClient .AsBuilder() .UseLogging(loggerFactory) - // Workaround for https://github.com/dotnet/extensions/issues/7204 - .Use(cc => new NonFunctionInvokingChatClient(cc, loggerFactory, sp)) .ConfigureOptions(o => { o.MaxOutputTokens = 350; diff --git a/PolyPilot/Services/AI/NonFunctionInvokingChatClient.cs b/PolyPilot/Services/AI/NonFunctionInvokingChatClient.cs deleted file mode 100644 index 8786ebee..00000000 --- a/PolyPilot/Services/AI/NonFunctionInvokingChatClient.cs +++ /dev/null @@ -1,229 +0,0 @@ -// Adapted from https://github.com/dotnet/maui/blob/main/src/AI/samples/Essentials.AI.Sample/AI/NonFunctionInvokingChatClient.cs -// Workaround for https://github.com/dotnet/extensions/issues/7204 -// Agent Framework's ChatClientAgent double-invokes tool calls when FunctionInvokingChatClient is in the pipeline. - -using System.Runtime.CompilerServices; -using System.Text.Json; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace PolyPilot.Services.AI; - -/// -/// A chat client wrapper that prevents Agent Framework from adding its own function invocation layer. -/// -/// -/// -/// Some chat clients handle tool invocation internally - when tools are registered, the underlying -/// service invokes them automatically and returns the results. However, Agent Framework's -/// ChatClientAgent also tries to invoke tools when it sees -/// in the response, causing double invocation. -/// -/// -/// This wrapper solves the problem by: -/// -/// The inner handler converts and -/// to internal marker types that doesn't recognize -/// We wrap the handler with a real , satisfying -/// Agent Framework's GetService<FunctionInvokingChatClient>() check so it won't create another -/// The outer layer unwraps the marker types back to the original content types for the caller -/// -/// -/// -public sealed partial class NonFunctionInvokingChatClient : DelegatingChatClient -{ - private readonly ILogger _logger; - - public NonFunctionInvokingChatClient( - IChatClient innerClient, - ILoggerFactory? loggerFactory = null, - IServiceProvider? serviceProvider = null) - : base(CreateInnerClient(innerClient, loggerFactory, serviceProvider)) - { - _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; - } - - private static FunctionInvokingChatClient CreateInnerClient( - IChatClient innerClient, - ILoggerFactory? loggerFactory, - IServiceProvider? serviceProvider) - { - ArgumentNullException.ThrowIfNull(innerClient); - var handler = new ToolCallPassThroughHandler(innerClient); - return new FunctionInvokingChatClient(handler, loggerFactory, serviceProvider); - } - - /// - public override async Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); - foreach (var message in response.Messages) - { - Unwrap(message.Contents); - } - return response; - } - - /// - public override async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) - { - Unwrap(update.Contents); - yield return update; - } - } - - private void Unwrap(IList contents) - { - for (var i = 0; i < contents.Count; i++) - { - if (contents[i] is ServerFunctionCallContent serverFcc) - { - var fcc = serverFcc.FunctionCallContent; - LogFunctionInvoking(fcc.Name, fcc.CallId, fcc.Arguments); - contents[i] = fcc; - } - else if (contents[i] is ServerFunctionResultContent serverFrc) - { - var frc = serverFrc.FunctionResultContent; - LogFunctionInvocationCompleted(frc.CallId, frc.Result); - contents[i] = frc; - } - } - } - - private void LogFunctionInvoking(string functionName, string callId, IDictionary? arguments) - { - if (_logger.IsEnabled(LogLevel.Trace) && arguments is not null) - { - var argsJson = JsonSerializer.Serialize(arguments, AIJsonUtilities.DefaultOptions); - LogToolInvokedSensitive(functionName, callId, argsJson); - } - else if (_logger.IsEnabled(LogLevel.Debug)) - { - LogToolInvoked(functionName, callId); - } - } - - private void LogFunctionInvocationCompleted(string callId, object? result) - { - if (_logger.IsEnabled(LogLevel.Trace) && result is not null) - { - var resultJson = result is string s ? s : JsonSerializer.Serialize(result, AIJsonUtilities.DefaultOptions); - LogToolInvocationCompletedSensitive(callId, resultJson); - } - else if (_logger.IsEnabled(LogLevel.Debug)) - { - LogToolInvocationCompleted(callId); - } - } - - [LoggerMessage(LogLevel.Debug, "Received tool call: {ToolName} (ID: {ToolCallId})")] - private partial void LogToolInvoked(string toolName, string toolCallId); - - [LoggerMessage(LogLevel.Trace, "Received tool call: {ToolName} (ID: {ToolCallId}) with arguments: {Arguments}")] - private partial void LogToolInvokedSensitive(string toolName, string toolCallId, string arguments); - - [LoggerMessage(LogLevel.Debug, "Received tool result for call ID: {ToolCallId}")] - private partial void LogToolInvocationCompleted(string toolCallId); - - [LoggerMessage(LogLevel.Trace, "Received tool result for call ID: {ToolCallId}: {Result}")] - private partial void LogToolInvocationCompletedSensitive(string toolCallId, string result); - - /// - /// Handler that wraps the inner client and converts tool call/result content to server-handled types. - /// Also strips FunctionCallContent/FunctionResultContent from input messages since Apple Intelligence - /// doesn't support them in conversation history (it handles tool calls internally). - /// - private sealed class ToolCallPassThroughHandler(IChatClient innerClient) : DelegatingChatClient(innerClient) - { - public override async Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - var response = await base.GetResponseAsync(StripToolContent(messages), options, cancellationToken).ConfigureAwait(false); - foreach (var message in response.Messages) - { - Wrap(message.Contents); - } - return response; - } - - public override async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await foreach (var update in base.GetStreamingResponseAsync(StripToolContent(messages), options, cancellationToken).ConfigureAwait(false)) - { - Wrap(update.Contents); - yield return update; - } - } - - /// - /// Remove FunctionCallContent/FunctionResultContent from history messages. - /// The AgentSession stores these from previous turns, but Apple Intelligence - /// can't accept them as input — it handles tool calls internally. - /// - private static IEnumerable StripToolContent(IEnumerable messages) - { - foreach (var msg in messages) - { - if (!msg.Contents.Any(c => c is FunctionCallContent or FunctionResultContent)) - { - yield return msg; - continue; - } - - // Filter out tool content, keep everything else - var filtered = msg.Contents - .Where(c => c is not FunctionCallContent and not FunctionResultContent) - .ToList(); - - if (filtered.Count > 0) - { - var clean = new ChatMessage(msg.Role, filtered); - yield return clean; - } - // Skip messages that were entirely tool content - } - } - - private static void Wrap(IList contents) - { - for (var i = 0; i < contents.Count; i++) - { - if (contents[i] is FunctionCallContent fcc) - contents[i] = new ServerFunctionCallContent(fcc); - else if (contents[i] is FunctionResultContent frc) - contents[i] = new ServerFunctionResultContent(frc); - } - } - } - - /// - /// Marker type for function calls that were already handled by the inner client. - /// - private sealed class ServerFunctionCallContent(FunctionCallContent functionCallContent) : AIContent - { - public FunctionCallContent FunctionCallContent { get; } = functionCallContent; - } - - /// - /// Marker type for function results that were already produced by the inner client. - /// - private sealed class ServerFunctionResultContent(FunctionResultContent functionResultContent) : AIContent - { - public FunctionResultContent FunctionResultContent { get; } = functionResultContent; - } -} From 6a4af86883f507b4e8e7373894eb9b415c6ef99c Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 6 Mar 2026 17:16:22 +0200 Subject: [PATCH 11/11] AppChat: use compact layout and increase popover size - 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> --- PolyPilot/Components/AppChatPopover.razor | 2 +- PolyPilot/Components/AppChatPopover.razor.css | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/PolyPilot/Components/AppChatPopover.razor b/PolyPilot/Components/AppChatPopover.razor index 0d5939f7..9753fafb 100644 --- a/PolyPilot/Components/AppChatPopover.razor +++ b/PolyPilot/Components/AppChatPopover.razor @@ -23,7 +23,7 @@ CurrentToolName="@currentToolName" ToolActivities="@toolActivities" IsProcessing="@isProcessing" - Compact="false" + Compact="true" Layout="ChatLayout.BothLeft" Style="ChatStyle.Minimal" />
diff --git a/PolyPilot/Components/AppChatPopover.razor.css b/PolyPilot/Components/AppChatPopover.razor.css index 42fa3f8b..c65eec7b 100644 --- a/PolyPilot/Components/AppChatPopover.razor.css +++ b/PolyPilot/Components/AppChatPopover.razor.css @@ -2,8 +2,8 @@ position: fixed; top: 50px; left: 16px; - width: 380px; - max-height: 560px; + width: 520px; + max-height: 72vh; background: var(--bg-primary, #1e1e2e); border: 1px solid var(--border-color, #444); border-radius: 12px; @@ -56,7 +56,7 @@ overflow-y: auto; overflow-x: hidden; min-height: 200px; - max-height: 440px; + max-height: calc(72vh - 100px); padding: 8px; }