Skip to content

Commit 939eff9

Browse files
mattleibowCopilot
andcommitted
Phase 1: AppChat as standalone popover (revert session integration)
- 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>
1 parent 30a8f1f commit 939eff9

10 files changed

Lines changed: 752 additions & 0 deletions

PolyPilot.Tests/PolyPilot.Tests.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
<PackageReference Include="GitHub.Copilot.SDK" Version="0.1.25" />
1313
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
1414
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
15+
<PackageReference Include="Microsoft.Extensions.AI" Version="10.3.0" />
16+
<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0-preview.260212.1" />
1517
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
1618
<PackageReference Include="xunit" Version="2.9.3" />
1719
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
@@ -66,6 +68,9 @@
6668
<Compile Include="../PolyPilot/Models/TutorialStep.cs" Link="Shared/TutorialStep.cs" />
6769
<Compile Include="../PolyPilot/Services/TutorialService.cs" Link="Shared/TutorialService.cs" />
6870
<Compile Include="../PolyPilot/BuildInfo.cs" Link="Shared/BuildInfo.cs" />
71+
<Compile Include="../PolyPilot/Services/AI/DirectLocalChatService.cs" Link="Shared/DirectLocalChatService.cs" />
72+
<Compile Include="../PolyPilot/Services/AI/AppChatTools.cs" Link="Shared/AppChatTools.cs" />
73+
<Compile Include="../PolyPilot/Services/AI/NonFunctionInvokingChatClient.cs" Link="Shared/NonFunctionInvokingChatClient.cs" />
6974
</ItemGroup>
7075

7176
</Project>
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
@using PolyPilot.Models
2+
@using PolyPilot.Services
3+
@using PolyPilot.Services.AI
4+
@inject DirectLocalChatService LocalChat
5+
@inject IJSRuntime JS
6+
7+
@if (IsVisible)
8+
{
9+
<div class="appchat-popover @(isExpanded ? "expanded" : "collapsed")">
10+
<div class="appchat-header" @onclick="ToggleExpand">
11+
<span class="appchat-title">✨ App Chat</span>
12+
<div class="appchat-header-actions">
13+
@if (messages.Count > 0)
14+
{
15+
<button class="appchat-clear-btn" title="Clear chat" @onclick="ClearChat" @onclick:stopPropagation="true">🗑</button>
16+
}
17+
<button class="appchat-close-btn" title="Close" @onclick="Close" @onclick:stopPropagation="true">✕</button>
18+
</div>
19+
</div>
20+
21+
@if (isExpanded)
22+
{
23+
<div class="appchat-body">
24+
<ChatMessageList Messages="@messages"
25+
StreamingContent="@streamingContent"
26+
IsProcessing="@isProcessing"
27+
Compact="true"
28+
Layout="ChatLayout.BothLeft"
29+
Style="ChatStyle.Minimal" />
30+
</div>
31+
32+
<div class="appchat-input-area">
33+
<input id="appchat-input"
34+
type="text"
35+
placeholder="Ask about your sessions..."
36+
disabled="@isProcessing"
37+
@onkeydown="HandleKeyDown" />
38+
<button class="appchat-send-btn"
39+
disabled="@isProcessing"
40+
@onclick="SendMessage">
41+
@(isProcessing ? "" : "")
42+
</button>
43+
</div>
44+
}
45+
</div>
46+
}
47+
48+
@code {
49+
[Parameter] public bool IsVisible { get; set; }
50+
[Parameter] public EventCallback OnClose { get; set; }
51+
52+
private List<ChatMessage> messages = new();
53+
private string streamingContent = "";
54+
private bool isProcessing;
55+
private bool isExpanded = true;
56+
private string sessionName = "appchat-" + Guid.NewGuid().ToString("N")[..8];
57+
private CancellationTokenSource? _cts;
58+
59+
private void ToggleExpand() => isExpanded = !isExpanded;
60+
61+
private async Task Close()
62+
{
63+
_cts?.Cancel();
64+
await OnClose.InvokeAsync();
65+
}
66+
67+
private void ClearChat()
68+
{
69+
_cts?.Cancel();
70+
messages.Clear();
71+
streamingContent = "";
72+
isProcessing = false;
73+
LocalChat.RemoveSession(sessionName);
74+
sessionName = "appchat-" + Guid.NewGuid().ToString("N")[..8];
75+
}
76+
77+
private async Task HandleKeyDown(KeyboardEventArgs e)
78+
{
79+
if (e.Key == "Enter" && !isProcessing)
80+
await SendMessage();
81+
}
82+
83+
private async Task SendMessage()
84+
{
85+
var prompt = await JS.InvokeAsync<string>("eval", "document.getElementById('appchat-input')?.value || ''");
86+
if (string.IsNullOrWhiteSpace(prompt)) return;
87+
88+
await JS.InvokeVoidAsync("eval", "document.getElementById('appchat-input').value = ''");
89+
90+
messages.Add(ChatMessage.UserMessage(prompt));
91+
isProcessing = true;
92+
streamingContent = "";
93+
StateHasChanged();
94+
95+
_cts?.Cancel();
96+
_cts = new CancellationTokenSource();
97+
98+
await LocalChat.SendPromptStreamingAsync(
99+
sessionName,
100+
prompt,
101+
onDelta: delta =>
102+
{
103+
streamingContent += delta;
104+
InvokeAsync(StateHasChanged);
105+
},
106+
onComplete: full =>
107+
{
108+
messages.Add(ChatMessage.AssistantMessage(full));
109+
streamingContent = "";
110+
isProcessing = false;
111+
InvokeAsync(StateHasChanged);
112+
},
113+
onError: error =>
114+
{
115+
messages.Add(ChatMessage.ErrorMessage(error));
116+
streamingContent = "";
117+
isProcessing = false;
118+
InvokeAsync(StateHasChanged);
119+
},
120+
cancellationToken: _cts.Token);
121+
}
122+
123+
public void Dispose()
124+
{
125+
_cts?.Cancel();
126+
_cts?.Dispose();
127+
LocalChat.RemoveSession(sessionName);
128+
}
129+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
.appchat-popover {
2+
position: fixed;
3+
bottom: 16px;
4+
right: 16px;
5+
width: 380px;
6+
max-height: 560px;
7+
background: var(--bg-primary, #1e1e2e);
8+
border: 1px solid var(--border-color, #444);
9+
border-radius: 12px;
10+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
11+
z-index: 200;
12+
display: flex;
13+
flex-direction: column;
14+
overflow: hidden;
15+
transition: max-height 0.2s ease;
16+
}
17+
18+
.appchat-popover.collapsed {
19+
max-height: 44px;
20+
}
21+
22+
.appchat-header {
23+
display: flex;
24+
align-items: center;
25+
justify-content: space-between;
26+
padding: 10px 14px;
27+
background: var(--bg-secondary, #2a2a3e);
28+
cursor: pointer;
29+
user-select: none;
30+
border-bottom: 1px solid var(--border-color, #444);
31+
flex-shrink: 0;
32+
}
33+
34+
.appchat-title {
35+
font-size: 13px;
36+
font-weight: 600;
37+
color: var(--text-primary, #e0e0e0);
38+
}
39+
40+
.appchat-header-actions {
41+
display: flex;
42+
gap: 4px;
43+
}
44+
45+
.appchat-clear-btn,
46+
.appchat-close-btn {
47+
background: none;
48+
border: none;
49+
color: var(--text-secondary, #999);
50+
cursor: pointer;
51+
font-size: 13px;
52+
padding: 2px 6px;
53+
border-radius: 4px;
54+
line-height: 1;
55+
}
56+
57+
.appchat-clear-btn:hover,
58+
.appchat-close-btn:hover {
59+
background: var(--bg-hover, rgba(255, 255, 255, 0.1));
60+
color: var(--text-primary, #e0e0e0);
61+
}
62+
63+
.appchat-body {
64+
flex: 1;
65+
overflow-y: auto;
66+
min-height: 200px;
67+
max-height: 440px;
68+
padding: 8px;
69+
}
70+
71+
.appchat-input-area {
72+
display: flex;
73+
gap: 6px;
74+
padding: 8px 10px;
75+
border-top: 1px solid var(--border-color, #444);
76+
background: var(--bg-secondary, #2a2a3e);
77+
flex-shrink: 0;
78+
}
79+
80+
.appchat-input-area input {
81+
flex: 1;
82+
background: var(--bg-primary, #1e1e2e);
83+
border: 1px solid var(--border-color, #555);
84+
border-radius: 6px;
85+
padding: 6px 10px;
86+
color: var(--text-primary, #e0e0e0);
87+
font-size: 13px;
88+
outline: none;
89+
}
90+
91+
.appchat-input-area input:focus {
92+
border-color: var(--accent-color, #7c3aed);
93+
}
94+
95+
.appchat-input-area input:disabled {
96+
opacity: 0.5;
97+
}
98+
99+
.appchat-send-btn {
100+
background: var(--accent-color, #7c3aed);
101+
border: none;
102+
border-radius: 6px;
103+
color: white;
104+
padding: 6px 12px;
105+
cursor: pointer;
106+
font-size: 14px;
107+
line-height: 1;
108+
}
109+
110+
.appchat-send-btn:hover:not(:disabled) {
111+
opacity: 0.85;
112+
}
113+
114+
.appchat-send-btn:disabled {
115+
opacity: 0.4;
116+
cursor: not-allowed;
117+
}
118+
119+
/* Mobile adjustments */
120+
@media (max-width: 768px) {
121+
.appchat-popover {
122+
bottom: 8px;
123+
right: 8px;
124+
left: 8px;
125+
width: auto;
126+
max-height: 50vh;
127+
}
128+
129+
.appchat-body {
130+
max-height: 35vh;
131+
}
132+
}

PolyPilot/Components/Layout/MainLayout.razor

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,17 @@
2424
</div>
2525

2626
<PolyPilot.Components.Tutorial.TutorialOverlay />
27+
28+
@if (!appChatOpen)
29+
{
30+
<button class="appchat-fab" title="App Chat" @onclick="() => appChatOpen = true">✨</button>
31+
}
32+
<AppChatPopover IsVisible="@appChatOpen" OnClose="() => appChatOpen = false" />
2733
</div>
2834

2935
@code {
3036
private bool flyoutOpen;
37+
private bool appChatOpen;
3138
private int fontSize = 20;
3239

3340
[Inject] private IJSRuntime JS { get; set; } = default!;

PolyPilot/Components/Layout/MainLayout.razor.css

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,29 @@ main {
124124
background: rgba(100, 140, 255, 0.4);
125125
}
126126
}
127+
128+
/* AppChat floating action button */
129+
.appchat-fab {
130+
position: fixed;
131+
bottom: 20px;
132+
right: 20px;
133+
width: 48px;
134+
height: 48px;
135+
border-radius: 50%;
136+
background: var(--accent-color, #7c3aed);
137+
border: none;
138+
color: white;
139+
font-size: 20px;
140+
cursor: pointer;
141+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
142+
z-index: 199;
143+
display: flex;
144+
align-items: center;
145+
justify-content: center;
146+
transition: transform 0.15s ease, box-shadow 0.15s ease;
147+
}
148+
149+
.appchat-fab:hover {
150+
transform: scale(1.1);
151+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
152+
}

PolyPilot/MauiProgram.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
using PolyPilot.Services;
2+
using PolyPilot.Services.AI;
3+
using Microsoft.Extensions.AI;
24
using Microsoft.Extensions.Logging;
35
using ZXing.Net.Maui.Controls;
46
using MauiDevFlow.Agent;
57
using MauiDevFlow.Blazor;
8+
#if MACCATALYST || IOS
9+
using Microsoft.Maui.Essentials.AI;
10+
#endif
611
#if MACCATALYST
712
using Microsoft.Maui.LifecycleEvents;
813
using UIKit;
@@ -106,6 +111,9 @@ public static MauiApp CreateMauiApp()
106111
builder.Services.AddSingleton<UsageStatsService>();
107112
builder.Services.AddSingleton<INotificationManagerService, NotificationManagerService>();
108113

114+
// Register local AI services (AppChat powered by on-device SLM)
115+
RegisterLocalAI(builder);
116+
109117
#if DEBUG
110118
builder.Services.AddBlazorWebViewDeveloperTools();
111119
builder.Logging.AddDebug();
@@ -128,4 +136,41 @@ private static void LogException(string source, Exception? ex)
128136
}
129137
catch { /* Don't throw in exception handler */ }
130138
}
139+
140+
/// <summary>
141+
/// Register the local AI IChatClient pipeline and DirectLocalChatService.
142+
/// On Apple platforms, uses AppleIntelligenceChatClient + NLEmbeddingGenerator.
143+
/// On other platforms, AppChat is unavailable (no SLM provider yet).
144+
/// </summary>
145+
private static void RegisterLocalAI(MauiAppBuilder builder)
146+
{
147+
#if MACCATALYST || IOS
148+
// Apple Intelligence provides on-device IChatClient + IEmbeddingGenerator
149+
#pragma warning disable MAUIAI0001 // Apple Intelligence is experimental
150+
builder.Services.AddSingleton<AppleIntelligenceChatClient>();
151+
152+
#pragma warning disable MEAI001 // EmbeddingToolReductionStrategy is experimental
153+
builder.Services.AddKeyedSingleton<IChatClient>("local", (sp, _) =>
154+
{
155+
var appleClient = sp.GetRequiredService<AppleIntelligenceChatClient>();
156+
#pragma warning restore MAUIAI0001
157+
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
158+
159+
return appleClient
160+
.AsBuilder()
161+
.UseLogging(loggerFactory)
162+
// Workaround for https://github.com/dotnet/extensions/issues/7204
163+
.Use(cc => new NonFunctionInvokingChatClient(cc, loggerFactory, sp))
164+
.ConfigureOptions(o =>
165+
{
166+
o.MaxOutputTokens = 350;
167+
o.AllowMultipleToolCalls = false;
168+
})
169+
.Build();
170+
});
171+
#pragma warning restore MEAI001
172+
173+
builder.Services.AddSingleton<DirectLocalChatService>();
174+
#endif
175+
}
131176
}

0 commit comments

Comments
 (0)