Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions PolyPilot.Tests/PolyPilot.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
<PackageReference Include="Markdig" Version="1.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.AI" Version="10.3.0" />
<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0-preview.260212.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageReference Include="sqlite-net-pcl" Version="1.9.172" />
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.11" />
Expand Down Expand Up @@ -76,6 +78,8 @@
<Compile Include="../PolyPilot/Services/TutorialService.cs" Link="Shared/TutorialService.cs" />
<Compile Include="../PolyPilot/Services/SessionMetricsExtractor.cs" Link="Shared/SessionMetricsExtractor.cs" />
<Compile Include="../PolyPilot/BuildInfo.cs" Link="Shared/BuildInfo.cs" />
<Compile Include="../PolyPilot/Services/AI/DirectLocalChatService.cs" Link="Shared/DirectLocalChatService.cs" />
<Compile Include="../PolyPilot/Services/AI/AppChatTools.cs" Link="Shared/AppChatTools.cs" />
<Compile Include="../PolyPilot/Models/SettingDescriptor.cs" Link="Shared/SettingDescriptor.cs" />
<Compile Include="../PolyPilot/Services/SettingsRegistry.cs" Link="Shared/SettingsRegistry.cs" />
<Compile Include="../PolyPilot/Services/HolidayThemeHelper.cs" Link="Shared/HolidayThemeHelper.cs" />
Expand Down
164 changes: 164 additions & 0 deletions PolyPilot/Components/AppChatPopover.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
@using PolyPilot.Models
@using PolyPilot.Services
@using PolyPilot.Services.AI
@inject DirectLocalChatService LocalChat
@inject IJSRuntime JS

@if (IsVisible)
{
<div class="appchat-popover">
<div class="appchat-header">
<span class="appchat-title">✨ App Chat</span>
<div class="appchat-header-actions">
@if (messages.Count > 0)
{
<button class="appchat-clear-btn" title="Clear chat" @onclick="ClearChat">🗑</button>
}
</div>
</div>

<div class="appchat-body">
<ChatMessageList Messages="@messages"
StreamingContent="@streamingContent"
CurrentToolName="@currentToolName"
ToolActivities="@toolActivities"
IsProcessing="@isProcessing"
Compact="true"
Layout="ChatLayout.BothLeft"
Style="ChatStyle.Minimal" />
</div>

<div class="appchat-input-area">
<input id="appchat-input"
type="text"
placeholder="Ask about your sessions..."
disabled="@isProcessing"
@onkeydown="HandleKeyDown" />
<button class="appchat-send-btn"
disabled="@isProcessing"
@onclick="SendMessage">
@(isProcessing ? "⏳" : "➤")
</button>
</div>
</div>
}

@code {
[Parameter] public bool IsVisible { get; set; }

private List<ChatMessage> messages = new();
private List<ToolActivity> 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<string>("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);
}
}
137 changes: 137 additions & 0 deletions PolyPilot/Components/AppChatPopover.razor.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
14 changes: 11 additions & 3 deletions PolyPilot/Components/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@

<div class="page @(flyoutOpen ? "flyout-open" : "")">
<div class="sidebar desktop-only">
<SessionSidebar OnToggleStatistics="ToggleStatistics" />
<SessionSidebar OnToggleAppChat="ToggleAppChat" OnToggleStatistics="ToggleStatistics" />
<div class="sidebar-resize-handle" @onmousedown="StartResize"></div>
</div>

<div class="mobile-top-bar mobile-only">
<SessionSidebar IsMobileTopBar="true" OnToggleFlyout="ToggleFlyout" FontSize="fontSize" OnFontSizeChange="HandleFontSizeChange" OnToggleStatistics="ToggleStatistics" />
<SessionSidebar IsMobileTopBar="true" OnToggleFlyout="ToggleFlyout" OnToggleAppChat="ToggleAppChat" FontSize="fontSize" OnFontSizeChange="HandleFontSizeChange" OnToggleStatistics="ToggleStatistics" />
</div>

<main>
Expand All @@ -21,15 +21,18 @@

<div class="flyout-backdrop mobile-only @(flyoutOpen ? "open" : "")" @onclick="CloseFlyout"></div>
<div class="flyout-panel mobile-only @(flyoutOpen ? "open" : "")">
<SessionSidebar IsFlyoutPanel="true" OnToggleFlyout="CloseFlyout" OnSessionSelected="CloseFlyout" OnToggleStatistics="ToggleStatistics" />
<SessionSidebar IsFlyoutPanel="true" OnToggleFlyout="CloseFlyout" OnSessionSelected="CloseFlyout" OnToggleAppChat="ToggleAppChat" OnToggleStatistics="ToggleStatistics" />
</div>

<PolyPilot.Components.Tutorial.TutorialOverlay />
<PolyPilot.Components.Layout.StatisticsPopup Visible="showStatistics" OnClose="CloseStatistics" />

<AppChatPopover IsVisible="@appChatOpen" />
</div>

@code {
private bool flyoutOpen;
private bool appChatOpen;
private bool showStatistics;
private int fontSize = 20;
private Timer? _holidayTimer;
Expand Down Expand Up @@ -201,6 +204,11 @@
flyoutOpen = false;
}

private void ToggleAppChat()
{
appChatOpen = !appChatOpen;
}

private void ToggleStatistics()
{
showStatistics = !showStatistics;
Expand Down
2 changes: 2 additions & 0 deletions PolyPilot/Components/Layout/SessionSidebar.razor
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ else
<a href="/" class="header-icon-btn @(currentPage == "/" || currentPage == "/dashboard" ? "active" : "")" title="Dashboard" @onclick="DashboardClicked" @onclick:preventDefault="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg></a>
<a href="/settings" class="header-icon-btn @(currentPage == "/settings" ? "active" : "")" title="Settings" @onclick='() => { CopilotService.SaveUiState("/settings"); currentPage = "/settings"; }'><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></a>
<a href="/tutorial" class="header-icon-btn @(currentPage == "/tutorial" ? "active" : "")" title="Tutorial" @onclick='() => { CopilotService.SaveUiState("/tutorial"); currentPage = "/tutorial"; }'><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></a>
<button class="header-icon-btn appchat-toggle" title="App Chat" @onclick="() => OnToggleAppChat.InvokeAsync()">✨</button>
<div class="info-popover">
<span class="info-trigger" title="Connection & shortcuts">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
Expand Down Expand Up @@ -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<int> OnFontSizeChange { get; set; }
[Parameter] public EventCallback OnToggleStatistics { get; set; }
Expand Down
7 changes: 7 additions & 0 deletions PolyPilot/Components/Layout/SessionSidebar.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading