diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index dc2f450b1b..887202dd2b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,54 +1,124 @@ -# AutoPilot.App - Copilot Instructions +# AutoPilot.App — Copilot Instructions -## Building and Launching +## Build & Deploy Commands -**IMPORTANT**: After making ANY code changes to this project, you MUST rebuild and relaunch the app using the relaunch script: +### Mac Catalyst (primary dev target) +```bash +./relaunch.sh # Build + seamless hot-relaunch (ALWAYS use this after code changes) +dotnet build -f net10.0-maccatalyst # Build only +``` +`relaunch.sh` builds, copies to staging, launches the new instance, waits for it to be ready, then kills the old one. Safe to run from a Copilot session inside the app itself. + +### Tests +```bash +cd ../AutoPilot.App.Tests && dotnet test # Run all tests +cd ../AutoPilot.App.Tests && dotnet test --filter "FullyQualifiedName~ChatMessageTests" # Run one test class +cd ../AutoPilot.App.Tests && dotnet test --filter "FullyQualifiedName~ChatMessageTests.UserMessage_SetsRoleAndType" # Single test +``` +The test project lives at `../AutoPilot.App.Tests/` (sibling directory). It includes source files from the main project via `` links because the MAUI project can't be directly referenced from a plain `net10.0` test project. When adding new model or utility classes, add a corresponding `` entry to the test csproj if the file has no MAUI dependencies. + +**Always run tests after modifying models, bridge messages, or serialization logic.** When adding new features or changing existing behavior, update or add tests to match. The tests serve as a living specification of the app's data contracts and parsing logic. + +### Android +```bash +dotnet build -f net10.0-android # Build only +dotnet build -f net10.0-android -t:Install # Build + deploy to connected device (use this, not bare `adb install`) +adb shell am start -n com.companyname.autopilot.app/crc645dd8ecec3b5d9ba6.MainActivity # Launch +``` +Fast Deployment requires `dotnet build -t:Install` — it pushes assemblies to `.__override__` on device. +### iOS (physical device) ```bash -./relaunch.sh +dotnet build -f net10.0-ios -r ios-arm64 # Build only +xcrun devicectl device install app --device bin/Debug/net10.0-ios/ios-arm64/AutoPilot.App.app/ +xcrun devicectl device process launch --device com.companyname.autopilot.app +``` +Do NOT use `dotnet build -t:Run` for physical iOS — it hangs waiting for the app to exit. + +### iOS Simulator +```bash +dotnet build -f net10.0-ios -t:Run -p:_DeviceName=:v2:udid= +``` + +### MauiDevFlow (UI inspection & debugging) +The app integrates `Redth.MauiDevFlow.Agent` + `Redth.MauiDevFlow.Blazor` for remote UI inspection. See `.claude/skills/maui-ai-debugging/SKILL.md` for the full command reference. +```bash +maui-devflow MAUI status # Agent connection +maui-devflow cdp status # CDP/Blazor WebView +maui-devflow MAUI tree # Visual tree +maui-devflow cdp snapshot # DOM snapshot (best for AI) +maui-devflow MAUI logs # Application ILogger output +``` +For Android, always run `adb reverse tcp:9223 tcp:9223 && adb reverse tcp:9222 tcp:9222` after deploy. + +## Architecture + +This is a .NET MAUI Blazor Hybrid app targeting Mac Catalyst, Android, and iOS. It manages multiple GitHub Copilot CLI sessions through a native GUI. + +### Three-Layer Stack +1. **Blazor UI** (`Components/`) — Razor components rendered in a BlazorWebView. All styling is CSS in `wwwroot/app.css` and scoped `.razor.css` files. +2. **Service Layer** (`Services/`) — `CopilotService` (singleton) manages sessions via `ConcurrentDictionary`. Events from the SDK arrive on background threads and are marshaled to the UI thread via `SynchronizationContext.Post`. +3. **SDK** — `GitHub.Copilot.SDK` (`CopilotClient`/`CopilotSession`) communicates with the Copilot CLI process via ACP (Agent Control Protocol) over stdio or TCP. + +### Connection Modes +- **Embedded** (default on desktop): SDK spawns copilot via stdio, dies with app. +- **Persistent**: App spawns a detached `copilot --headless` server tracked via PID file; survives restarts. +- **Remote**: Connects to a remote server URL (e.g., DevTunnel). Only mode available on mobile. + +### WebSocket Bridge (Remote Viewer Protocol) +`WsBridgeServer` runs on the desktop app and exposes session state over WebSocket. `WsBridgeClient` runs on mobile apps to receive live updates and send commands. The protocol is defined in `Models/BridgeMessages.cs` with typed payloads and message type constants in `BridgeMessageTypes`. + +`DevTunnelService` manages a `devtunnel host` process to expose the bridge over the internet, with QR code scanning for easy mobile setup (`QrScannerPage.xaml`). + +### Platform Differences +`Models/PlatformHelper.cs` exposes `IsDesktop`/`IsMobile` and controls which `ConnectionMode`s are available. Mobile can only use Remote mode. Desktop defaults to Embedded. + +## Critical Conventions + +### No `static readonly` fields that call platform APIs +`static readonly` fields are evaluated during type initialization — before MAUI's platform layer is ready on Android/iOS. This causes `TypeInitializationException` crashes. + +**Always use lazy properties instead:** +```csharp +// ❌ WRONG — crashes on Android/iOS +private static readonly string MyPath = Path.Combine(FileSystem.AppDataDirectory, "file.json"); + +// ✅ CORRECT — deferred until first access +private static string? _myPath; +private static string MyPath => _myPath ??= Path.Combine(FileSystem.AppDataDirectory, "file.json"); ``` -**NEVER** use `dotnet build` + `open` separately. The relaunch script: -1. Builds the app with `dotnet build -f net10.0-maccatalyst` -2. Copies the built app to a staging directory -3. Launches a NEW instance with `open -n` (so the old one stays alive) - -This is critical because: -- The script captures old PIDs, launches new instance, waits for it to start, then kills old ones -- This ensures seamless handoff — new app is running before old one dies -- The 3-second grace period lets the new UI fully initialize -- Safe even when called from a Copilot session inside the app - -## Project Structure - -- **Framework**: .NET MAUI Blazor Hybrid (Mac Catalyst) -- **SDK**: `GitHub.Copilot.SDK` 0.1.22 - talks to Copilot CLI via ACP (Agent Control Protocol) -- **Linker**: The csproj has `` — do NOT remove this or SDK event types get stripped -- **Sandbox**: Disabled in `Platforms/MacCatalyst/Entitlements.plist` — required for spawning copilot CLI - -## Key Files - -- `Services/CopilotService.cs` — Core SDK wrapper, session management, event handling -- `Components/Pages/Home.razor` — Chat UI -- `Components/Pages/Dashboard.razor` — Multi-session orchestrator view -- `Components/Layout/SessionSidebar.razor` — Session list, create/resume -- `Models/AgentSessionInfo.cs` — Session info model -- `Models/ChatMessage.cs` — Chat message record - -## SDK Event Handling - -The SDK sends `AssistantMessageEvent` (not deltas) for responses. Key event types: -- `AssistantMessageEvent` — full response content -- `AssistantMessageDeltaEvent` — streaming deltas (may not always be used) -- `SessionIdleEvent` — turn is complete -- `ToolExecutionStartEvent` — tool call started -- `ToolExecutionCompleteEvent` — tool call finished -- `AssistantIntentEvent` — intent/activity update -- `SessionStartEvent` — has SessionId assignment - -## Performance Notes - -- Avoid `@bind:event="oninput"` on text inputs — causes round-trip lag on every keystroke -- Use plain HTML inputs with JS event listeners for fast typing -- Read input values via `JS.InvokeAsync("eval", "document.getElementById('id')?.value")` on submit -- Materialize `IEnumerable` from disk reads to `List` to avoid re-reading on every render +This applies to `FileSystem.AppDataDirectory`, `Environment.GetFolderPath()`, and any Android/iOS-specific API. See `CopilotService.cs` (lines 19-59) and `ConnectionSettings.cs` (lines 29-53) for examples. + +### File paths on iOS/Android vs desktop +- **Desktop**: Use `Environment.SpecialFolder.UserProfile` → `~/.copilot/` +- **iOS/Android**: Use `FileSystem.AppDataDirectory` (persistent across restarts). `Environment.SpecialFolder.LocalApplicationData` on iOS resolves to a cache directory that can be purged. +- Always wrap in try/catch with `Path.GetTempPath()` fallback. + +### Linker / Trimmer +`` in the csproj prevents the linker from stripping SDK event types needed for runtime pattern matching. Do NOT remove this. + +### Mac Catalyst Sandbox +Disabled in `Platforms/MacCatalyst/Entitlements.plist` — required for spawning copilot CLI processes and binding network ports. + +### Edge-to-edge on Android (.NET 10) +.NET 10 MAUI defaults `ContentPage.SafeAreaEdges` to `None` (edge-to-edge). For this Blazor app, safe area insets are handled entirely in CSS/JS — do NOT set `SafeAreaEdges="Container"` on MainPage.xaml or add `padding-bottom` on body, as this causes double-padding. + +### SDK Event Flow +When a prompt is sent, the SDK emits events processed by `HandleSessionEvent` in order: +1. `AssistantTurnStartEvent` → "Thinking..." indicator +2. `AssistantMessageDeltaEvent` → streaming content chunks +3. `AssistantMessageEvent` → full message (may include tool requests) +4. `ToolExecutionStartEvent` / `ToolExecutionCompleteEvent` → tool activity +5. `AssistantIntentEvent` → intent/plan updates +6. `SessionIdleEvent` → turn complete, response finalized + +### Blazor Input Performance +Avoid `@bind:event="oninput"` — causes round-trip lag per keystroke. Use plain HTML inputs with JS event listeners and read values via `JS.InvokeAsync("eval", "document.getElementById('id')?.value")` on submit. + +### Session Persistence +- Active sessions: `~/.copilot/autopilot-active-sessions.json` +- Session state: `~/.copilot/session-state//events.jsonl` (SDK-managed) +- UI state: `~/.copilot/autopilot-ui-state.json` +- Settings: `~/.copilot/autopilot-settings.json` +- Crash log: `~/.copilot/autopilot-crash.log` diff --git a/Components/ChatMessageList.razor b/Components/ChatMessageList.razor index 12e6427e57..47212cdb9a 100644 --- a/Components/ChatMessageList.razor +++ b/Components/ChatMessageList.razor @@ -1,7 +1,7 @@ @using AutoPilot.App.Models @using Markdig -@* Shared chat message list — used by both Home.razor (full) and Dashboard.razor (compact) *@ +@* Shared chat message list — used by Dashboard.razor (grid=compact, expanded=full) *@
@if (!Messages.Any() && string.IsNullOrEmpty(StreamingContent)) diff --git a/Components/Layout/SessionSidebar.razor b/Components/Layout/SessionSidebar.razor index 2770fdf0b2..e413aa7fd7 100644 --- a/Components/Layout/SessionSidebar.razor +++ b/Components/Layout/SessionSidebar.razor @@ -12,8 +12,7 @@ AutoPilot
@@ -76,8 +75,7 @@ else }

@@ -339,10 +337,7 @@ else private void RefreshSessions() { sessions = CopilotService.GetAllSessions().ToList(); - if (showPersistedSessions) - { - LoadPersistedSessions(); - } + LoadPersistedSessions(); InvokeAsync(StateHasChanged); } @@ -357,10 +352,6 @@ else private void TogglePersistedSessions() { showPersistedSessions = !showPersistedSessions; - if (showPersistedSessions) - { - LoadPersistedSessions(); - } } private void OnSessionNameInput(ChangeEventArgs e) diff --git a/Components/Pages/Dashboard.razor b/Components/Pages/Dashboard.razor index e776464790..f0b45e3967 100644 --- a/Components/Pages/Dashboard.razor +++ b/Components/Pages/Dashboard.razor @@ -1,40 +1,205 @@ +@page "/" @page "/dashboard" @using AutoPilot.App.Services @using AutoPilot.App.Models @inject CopilotService CopilotService @inject IJSRuntime JS @inject NavigationManager Nav +@inject DevTunnelService DevTunnelService @implements IDisposable -
-
-

Session Orchestrator

- @sessions.Count active sessions -
- - @if (!sessions.Any()) +
+ @if (!CopilotService.IsInitialized) { +
+
+

Connecting to Copilot...

+ @if (!string.IsNullOrEmpty(initError)) + { +

@initError

+ + } +
+ } + else if (!sessions.Any() && expandedSession == null) + { +
+

Session Orchestrator

+

No active sessions. Create sessions from the sidebar to orchestrate them here.

} + else if (expandedSession != null) + { + @* Expanded single-session view — mirrors Chat (Home.razor) layout *@ + var session = sessions.FirstOrDefault(s => s.Name == expandedSession); + if (session != null) + { + var isCompleted = completedSessions.Contains(session.Name); + var cardClass = session.IsProcessing ? "processing" : isCompleted ? "completed" : "idle"; +
+
+
+

@session.Name

+
+
+ @if (session.IsProcessing) + { + + } + @GetExpandedModel(session) + @if (session.SessionId != null) + { + 🔗 @session.SessionId[..Math.Min(8, session.SessionId.Length)] 📋 + } + @if (!string.IsNullOrEmpty(session.WorkingDirectory)) + { + 📁 @ShortenPath(session.WorkingDirectory) + } + @if (!string.IsNullOrEmpty(session.GitBranch)) + { + @session.GitBranch + } + +
+
+ +
+ @{ var expandedMessages = GetWindowedMessages(session.Name, session.History); } + @if (session.History.Count > expandedMessages.Count) + { + + } + +
+ + @if (errorBySession.TryGetValue(session.Name, out var err2)) + { +
+ ⚠️ @err2 + +
+ } + +
+ @if (intentBySession.TryGetValue(session.Name, out var intent2) && !string.IsNullOrEmpty(intent2)) + { +
💭 @intent2
+ } + @if (session.MessageQueue.Any()) + { +
+
+ 📋 Queued (@session.MessageQueue.Count) + +
+ @for (var i = 0; i < session.MessageQueue.Count; i++) + { + var index = i; + var msg = session.MessageQueue[i]; +
+ @(index + 1) + @Truncate(msg, 80) + +
+ } +
+ } +
+ + @if (session.IsProcessing) + { + + } + +
+
+
+ + +
+ · + + @if (usageBySession.TryGetValue(session.Name, out var usage2)) + { + @if (usage2.InputTokens.HasValue || usage2.OutputTokens.HasValue) + { + · + ↑@FormatTokenCount(usage2.InputTokens ?? 0) ↓@FormatTokenCount(usage2.OutputTokens ?? 0) + } + @if (usage2.CurrentTokens.HasValue && usage2.TokenLimit.HasValue) + { + · + @FormatTokenCount(usage2.CurrentTokens.Value)/@FormatTokenCount(usage2.TokenLimit.Value) ctx + } + } + · + @session.History.Count msgs + · + + + @(fontSize)px + + +
+
+
+ } + } else { +
+

Session Orchestrator

+ @sessions.Count active sessions +
@foreach (var session in sessions) { var isCompleted = completedSessions.Contains(session.Name); var cardClass = session.IsProcessing ? "processing" : isCompleted ? "completed" : "idle"; -
+

@session.Name

- @session.Model + @if (session.IsProcessing) + { + + }
- +
+ @session.Model + @if (!string.IsNullOrEmpty(session.WorkingDirectory)) + { + 📁 @ShortenPath(session.WorkingDirectory) + } + @if (!string.IsNullOrEmpty(session.GitBranch)) + { + @session.GitBranch + } +
@{ @@ -44,32 +209,48 @@ }
+ @if (errorBySession.TryGetValue(session.Name, out var errG)) + { +
+ ⚠️ @errG + +
+ } + + @if (session.MessageQueue.Any()) + { +
+ 📋 Queued (@session.MessageQueue.Count) + +
+ } + + @if (intentBySession.TryGetValue(session.Name, out var intentG) && !string.IsNullOrEmpty(intentG)) + { +
💭 @intentG
+ } +
- + } +
}
- -
-

Broadcast to All

-
- - -
-
}
@@ -78,13 +259,80 @@ private HashSet completedSessions = new(); private Dictionary streamingBySession = new(); private Dictionary activityBySession = new(); + private Dictionary draftBySession = new(); + private Dictionary expandedMessageCounts = new(); + private Dictionary> toolActivitiesBySession = new(); + private Dictionary currentToolBySession = new(); + private Dictionary intentBySession = new(); + private Dictionary errorBySession = new(); + private Dictionary usageBySession = new(); + private Dictionary planModeBySession = new(); + private Dictionary modelOverrideBySession = new(); + private int fontSize = 20; + private string? expandedSession; + private string? _focusedInputId; + private int _cursorStart; + private int _cursorEnd; + private DotNetObjectReference? _dotNetRef; + private string? initError; - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { CopilotService.OnStateChanged += RefreshState; CopilotService.OnSessionComplete += HandleComplete; CopilotService.OnContentReceived += HandleContent; CopilotService.OnActivity += HandleActivity; + CopilotService.OnToolStarted += HandleToolStarted; + CopilotService.OnToolCompleted += HandleToolCompleted; + CopilotService.OnIntentChanged += HandleIntentChanged; + CopilotService.OnUsageInfoChanged += HandleUsageInfoChanged; + CopilotService.OnError += HandleError; + CopilotService.OnTurnStart += HandleTurnStart; + CopilotService.OnTurnEnd += HandleTurnEnd; + + await Initialize(); + } + + private async Task Initialize() + { + initError = null; + try + { + await CopilotService.InitializeAsync(); + + if (CopilotService.NeedsConfiguration) + { + Nav.NavigateTo("/settings"); + return; + } + + var uiState = CopilotService.LoadUiState(); + if (uiState != null) + { + if (!string.IsNullOrEmpty(uiState.ActiveSession)) + CopilotService.SetActiveSession(uiState.ActiveSession); + if (uiState.FontSize >= 12 && uiState.FontSize <= 24) + fontSize = uiState.FontSize; + if (uiState.CurrentPage == "/settings") + Nav.NavigateTo("/settings"); + } + + var connSettings = ConnectionSettings.Load(); + if (connSettings.AutoStartTunnel && DevTunnelService.State == TunnelState.NotStarted) + { + _ = Task.Run(async () => + { + try { await DevTunnelService.HostAsync(connSettings.Port); } + catch (Exception ex) { Console.WriteLine($"[AutoStart] Tunnel failed: {ex.Message}"); } + }); + } + } + catch (Exception ex) + { + initError = ex.Message; + Console.WriteLine($"Init error: {ex}"); + } + RefreshState(); } @@ -92,37 +340,107 @@ { if (firstRender) { - // Set up Enter and Tab key handlers for card inputs await JS.InvokeVoidAsync("eval", @" - document.addEventListener('keydown', function(e) { - var isCardInput = e.target.matches && e.target.matches('.card-input input'); - var isBroadcast = e.target.id === 'broadcastInput'; - if (e.key === 'Enter' && isCardInput) { - e.preventDefault(); - e.target.closest('.card-input').querySelector('button')?.click(); - } - if (e.key === 'Enter' && isBroadcast) { - e.preventDefault(); - document.querySelector('.broadcast-input button')?.click(); - } - // Tab / Shift+Tab to cycle between card inputs only when focused on one - if (e.key === 'Tab' && (isCardInput || isBroadcast)) { - e.preventDefault(); - e.stopImmediatePropagation(); - var inputs = Array.from(document.querySelectorAll('.card-input input:not(:disabled), #broadcastInput')); - if (inputs.length < 2) return; - var idx = inputs.indexOf(e.target); - if (idx < 0) idx = 0; - idx = e.shiftKey ? (idx - 1 + inputs.length) % inputs.length : (idx + 1) % inputs.length; - inputs[idx].focus(); - } - }); + if (!window.__dashboardKeydownRegistered) { + window.__dashboardKeydownRegistered = true; + document.addEventListener('keydown', function(e) { + var sel = '.card-input input, .card-input textarea, .input-row textarea'; + var isInput = e.target.matches && e.target.matches(sel); + if (e.key === 'Enter' && !e.shiftKey && isInput) { + e.preventDefault(); + var container = e.target.closest('.card-input') || e.target.closest('.input-row'); + if (container) { + var btn = container.querySelector('.send-btn') || container.querySelectorAll('button')[container.querySelectorAll('button').length - 1]; + if (btn) btn.click(); + } + } + if (e.key === 'Tab' && isInput) { + e.preventDefault(); + e.stopImmediatePropagation(); + var expandedCard = document.querySelector('.expanded-card'); + if (expandedCard && window.__dashRef) { + window.__dashRef.invokeMethodAsync('JsCycleExpandedSession', e.shiftKey); + return; + } + var inputs = Array.from(document.querySelectorAll(sel)); + if (inputs.length < 2) return; + var idx = inputs.indexOf(e.target); + if (idx < 0) idx = 0; + idx = e.shiftKey ? (idx - 1 + inputs.length) % inputs.length : (idx + 1) % inputs.length; + inputs[idx].focus(); + } + if ((e.metaKey || e.ctrlKey) && e.key === 'e') { + e.preventDefault(); + var collapseBtn = document.querySelector('.collapse-card-btn'); + if (collapseBtn) { collapseBtn.click(); return; } + var card = isInput ? e.target.closest('.session-card') : document.querySelector('.session-card'); + if (card && card.dataset.session && window.__dashRef) { + window.__dashRef.invokeMethodAsync('JsExpandSession', card.dataset.session); + } + } + if (e.key === 'Escape') { + var collapseBtn = document.querySelector('.collapse-card-btn'); + if (collapseBtn) collapseBtn.click(); + } + }); + } "); + var dotNetRef = DotNetObjectReference.Create(this); + await JS.InvokeVoidAsync("eval", "window.__dashRef = null;"); + // Store .NET ref so keydown handler can call back + _dotNetRef = dotNetRef; + await JS.InvokeVoidAsync("eval", "void 0"); // no-op to sync + // We need to pass the ref via a named function + await JS.InvokeVoidAsync("eval", "window.__setDashRef = function(ref) { window.__dashRef = ref; };"); + await JS.InvokeVoidAsync("__setDashRef", _dotNetRef); } // Auto-scroll all card message containers to bottom + // Restore draft text, focus, and cursor position — all in ONE JS call + var draftsJson = "{}"; + if (draftBySession.Count > 0) + { + var draftMap = new Dictionary(); + foreach (var (name, draft) in draftBySession) + { + if (!string.IsNullOrEmpty(draft)) + draftMap[$"input-{name.Replace(" ", "-")}"] = draft; + } + draftsJson = System.Text.Json.JsonSerializer.Serialize(draftMap); + } + var focusId = _focusedInputId ?? ""; + var selStart = _cursorStart; + var selEnd = _cursorEnd; + _focusedInputId = null; + + await JS.InvokeVoidAsync("eval", $@" + (function() {{ + document.querySelectorAll('.card-messages, .messages').forEach(function(el) {{ el.scrollTop = el.scrollHeight; }}); + var drafts = {draftsJson}; + for (var id in drafts) {{ + var el = document.getElementById(id); + if (el) el.value = drafts[id]; + }} + var focusId = '{focusId}'; + if (focusId) {{ + var fel = document.getElementById(focusId); + if (fel) {{ fel.focus(); fel.setSelectionRange({selStart}, {selEnd}); }} + }} + }})(); + "); + + // Auto-load more messages when scrolled to top (IntersectionObserver on load-more buttons) await JS.InvokeVoidAsync("eval", @" - document.querySelectorAll('.card-messages').forEach(el => el.scrollTop = el.scrollHeight); + if (!window.__loadMoreObserver) { + window.__loadMoreObserver = new IntersectionObserver(function(entries) { + entries.forEach(function(entry) { + if (entry.isIntersecting) entry.target.click(); + }); + }, { threshold: 0.1 }); + } + document.querySelectorAll('.load-more-btn').forEach(function(btn) { + if (!btn.__observed) { btn.__observed = true; window.__loadMoreObserver.observe(btn); } + }); "); } @@ -159,6 +477,88 @@ InvokeAsync(StateHasChanged); } + private void HandleToolStarted(string sessionName, string toolName, string callId, string? inputSummary) + { + currentToolBySession[sessionName] = toolName; + if (!toolActivitiesBySession.ContainsKey(sessionName)) + toolActivitiesBySession[sessionName] = new(); + toolActivitiesBySession[sessionName].Add(new ToolActivity + { + Name = toolName, + CallId = callId, + Input = inputSummary, + StartedAt = DateTime.Now + }); + InvokeAsync(StateHasChanged); + } + + private void HandleToolCompleted(string sessionName, string callId, string result, bool success) + { + if (toolActivitiesBySession.TryGetValue(sessionName, out var activities)) + { + var activity = activities.LastOrDefault(a => a.CallId == callId); + if (activity != null) + { + activity.IsComplete = true; + activity.IsSuccess = success; + activity.Result = result; + activity.CompletedAt = DateTime.Now; + } + } + currentToolBySession.Remove(sessionName); + InvokeAsync(StateHasChanged); + } + + private void HandleIntentChanged(string sessionName, string intent) + { + intentBySession[sessionName] = intent; + InvokeAsync(StateHasChanged); + } + + private void HandleUsageInfoChanged(string sessionName, SessionUsageInfo info) + { + if (usageBySession.TryGetValue(sessionName, out var current)) + { + usageBySession[sessionName] = new SessionUsageInfo( + info.Model ?? current.Model, + info.CurrentTokens ?? current.CurrentTokens, + info.TokenLimit ?? current.TokenLimit, + info.InputTokens ?? current.InputTokens, + info.OutputTokens ?? current.OutputTokens); + } + else + { + usageBySession[sessionName] = info; + } + InvokeAsync(StateHasChanged); + } + + private void HandleError(string sessionName, string error) + { + if (error.Contains("cancell", StringComparison.OrdinalIgnoreCase)) return; + errorBySession[sessionName] = error; + InvokeAsync(StateHasChanged); + } + + private void HandleTurnStart(string sessionName) + { + currentToolBySession.Remove(sessionName); + toolActivitiesBySession[sessionName] = new(); + InvokeAsync(StateHasChanged); + } + + private void HandleTurnEnd(string sessionName) + { + intentBySession.Remove(sessionName); + currentToolBySession.Remove(sessionName); + InvokeAsync(StateHasChanged); + } + + private void DismissError(string sessionName) + { + errorBySession.Remove(sessionName); + } + private async Task SendFromCard(string sessionName) { var inputId = $"input-{sessionName.Replace(" ", "-")}"; @@ -166,12 +566,25 @@ if (string.IsNullOrWhiteSpace(prompt)) return; await JS.InvokeVoidAsync("eval", $"document.getElementById('{inputId}').value = ''"); + draftBySession.Remove(sessionName); + + var finalPrompt = prompt.Trim(); + if (planModeBySession.GetValueOrDefault(sessionName)) + finalPrompt = $"[[PLAN]] {finalPrompt}"; + + var session = sessions.FirstOrDefault(s => s.Name == sessionName); + if (session?.IsProcessing == true) + { + CopilotService.EnqueueMessage(sessionName, finalPrompt); + return; + } + streamingBySession.Remove(sessionName); activityBySession.Remove(sessionName); try { - _ = CopilotService.SendPromptAsync(sessionName, prompt.Trim()).ContinueWith(t => + _ = CopilotService.SendPromptAsync(sessionName, finalPrompt).ContinueWith(t => { if (t.IsFaulted) { @@ -188,36 +601,199 @@ } } - private async Task BroadcastMessage() + private async Task ExpandSession(string sessionName) { - var prompt = await JS.InvokeAsync("eval", "document.getElementById('broadcastInput')?.value || ''"); - if (string.IsNullOrWhiteSpace(prompt)) return; + await SaveDraftsAndCursor(); + expandedSession = sessionName; + } - await JS.InvokeVoidAsync("eval", "document.getElementById('broadcastInput').value = ''"); + private async Task CollapseExpanded() + { + await SaveDraftsAndCursor(); + expandedSession = null; + } - foreach (var session in sessions.Where(s => !s.IsProcessing)) + private async Task SaveDraftsAndCursor() + { + var json = await JS.InvokeAsync("eval", @" + (function() { + var focused = document.activeElement; + var sel = '.card-input input, .card-input textarea, .input-row textarea'; + var focusId = (focused && focused.id && focused.matches(sel)) ? focused.id : null; + var selStart = focusId ? (focused.selectionStart || 0) : 0; + var selEnd = focusId ? (focused.selectionEnd || 0) : 0; + var items = Array.from(document.querySelectorAll(sel)) + .filter(el => el.id) + .map(el => ({ id: el.id, value: el.value || '' })); + return JSON.stringify({ focusId: focusId, selStart: selStart, selEnd: selEnd, items: items }); + })() + "); + if (!string.IsNullOrEmpty(json)) { try { - _ = CopilotService.SendPromptAsync(session.Name, prompt.Trim()); - } - catch (Exception ex) - { - Console.WriteLine($"Error broadcasting to {session.Name}: {ex.Message}"); + var state = System.Text.Json.JsonSerializer.Deserialize(json); + if (state != null) + { + _focusedInputId = state.FocusId; + _cursorStart = state.SelStart; + _cursorEnd = state.SelEnd; + draftBySession.Clear(); + foreach (var item in state.Items ?? []) + { + var name = item.Id.Replace("input-", "").Replace("-", " "); + draftBySession[name] = item.Value; + } + } } + catch { } } } + private record DraftItem( + [property: System.Text.Json.Serialization.JsonPropertyName("id")] string Id, + [property: System.Text.Json.Serialization.JsonPropertyName("value")] string Value); + + private record DraftState( + [property: System.Text.Json.Serialization.JsonPropertyName("focusId")] string? FocusId, + [property: System.Text.Json.Serialization.JsonPropertyName("selStart")] int SelStart, + [property: System.Text.Json.Serialization.JsonPropertyName("selEnd")] int SelEnd, + [property: System.Text.Json.Serialization.JsonPropertyName("items")] List? Items); + + private async Task StopSession(string sessionName) + { + await CopilotService.AbortSessionAsync(sessionName); + } + private async Task CloseSession(string sessionName) { await CopilotService.CloseSessionAsync(sessionName); + if (expandedSession == sessionName) + expandedSession = null; + } + + private void ClearQueue(string sessionName) + { + CopilotService.ClearQueue(sessionName); } - private void GoToSession(string sessionName) + private async Task GoToSession(string sessionName) { CopilotService.SwitchSession(sessionName); - CopilotService.SaveUiState("/", sessionName); - Nav.NavigateTo("/"); + await SaveDraftsAndCursor(); + expandedSession = sessionName; + _focusedInputId = $"input-{sessionName.Replace(" ", "-")}"; + _cursorStart = 0; + _cursorEnd = 0; + } + + private static string ShortenPath(string path) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(home) && path.StartsWith(home)) + return "~" + path[home.Length..]; + var parts = path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return parts.Length > 3 ? string.Join("/", parts[^3..]) : path; + } + + [JSInvokable] + public async Task JsExpandSession(string sessionName) + { + await SaveDraftsAndCursor(); + expandedSession = sessionName; + StateHasChanged(); + } + + [JSInvokable] + public async Task JsCycleExpandedSession(bool reverse) + { + if (expandedSession == null || sessions.Count < 2) return; + await SaveDraftsAndCursor(); + var idx = sessions.FindIndex(s => s.Name == expandedSession); + if (idx < 0) idx = 0; + idx = reverse ? (idx - 1 + sessions.Count) % sessions.Count : (idx + 1) % sessions.Count; + expandedSession = sessions[idx].Name; + _focusedInputId = $"input-{expandedSession.Replace(" ", "-")}"; + _cursorStart = 0; + _cursorEnd = 0; + StateHasChanged(); + } + + private const int DefaultMessageWindow = 25; + + private List GetWindowedMessages(string sessionName, IReadOnlyList history) + { + var limit = expandedMessageCounts.TryGetValue(sessionName, out var c) ? c : DefaultMessageWindow; + try + { + if (history.Count <= limit) return history.ToList(); + return history.Skip(history.Count - limit).ToList(); + } + catch (InvalidOperationException) + { + return history.ToArray().TakeLast(limit).ToList(); + } + } + + private void LoadMoreExpandedMessages(string sessionName) + { + var current = expandedMessageCounts.TryGetValue(sessionName, out var c) ? c : DefaultMessageWindow; + expandedMessageCounts[sessionName] = current + 25; + } + + // === Model, plan mode, font, token helpers === + + private readonly string[] availableModels = new[] + { + "claude-opus-4.6", "claude-sonnet-4.5", "claude-sonnet-4", "claude-haiku-4.5", + "gpt-5.2", "gpt-5.1", "gpt-5", "gpt-5-mini", "gemini-3-pro-preview", + }; + + private string GetExpandedModel(AgentSessionInfo session) + { + if (usageBySession.TryGetValue(session.Name, out var u) && !string.IsNullOrEmpty(u.Model) && availableModels.Contains(u.Model)) + return u.Model; + if (modelOverrideBySession.TryGetValue(session.Name, out var m)) + return m; + if (!string.IsNullOrEmpty(session.Model) && session.Model != "resumed" && availableModels.Contains(session.Model)) + return session.Model; + return availableModels[0]; + } + + private void SetExpandedModel(AgentSessionInfo session, string? model) + { + if (string.IsNullOrEmpty(model)) return; + modelOverrideBySession[session.Name] = model; + if (session.Model != "resumed") session.Model = model; + } + + private void SetPlanMode(string sessionName, bool enabled) => planModeBySession[sessionName] = enabled; + + private void IncreaseFontSize() { if (fontSize < 24) { fontSize += 2; ApplyFontSize(); } } + private void DecreaseFontSize() { if (fontSize > 12) { fontSize -= 2; ApplyFontSize(); } } + private void ResetFontSize() { fontSize = 20; ApplyFontSize(); } + private void ApplyFontSize() + { + _ = JS.InvokeVoidAsync("setAppFontSize", fontSize); + CopilotService.SaveUiState("/dashboard", fontSize: fontSize); + } + + private static string FormatTokenCount(int count) => + count >= 1000 ? $"{count / 1000}k" : count.ToString(); + + private static string Truncate(string s, int max) => + s.Length <= max ? s : s[..max] + "…"; + + private void RemoveQueuedMessage(string sessionName, int index) + { + var session = sessions.FirstOrDefault(s => s.Name == sessionName); + if (session != null && index >= 0 && index < session.MessageQueue.Count) + session.MessageQueue.RemoveAt(index); + } + + private async Task CopyToClipboard(string text) + { + try { await JS.InvokeVoidAsync("navigator.clipboard.writeText", text); } catch { } } public void Dispose() @@ -226,5 +802,13 @@ CopilotService.OnSessionComplete -= HandleComplete; CopilotService.OnContentReceived -= HandleContent; CopilotService.OnActivity -= HandleActivity; + CopilotService.OnToolStarted -= HandleToolStarted; + CopilotService.OnToolCompleted -= HandleToolCompleted; + CopilotService.OnIntentChanged -= HandleIntentChanged; + CopilotService.OnUsageInfoChanged -= HandleUsageInfoChanged; + CopilotService.OnError -= HandleError; + CopilotService.OnTurnStart -= HandleTurnStart; + CopilotService.OnTurnEnd -= HandleTurnEnd; + _dotNetRef?.Dispose(); } } diff --git a/Components/Pages/Dashboard.razor.css b/Components/Pages/Dashboard.razor.css index 361d19f239..5efbf14420 100644 --- a/Components/Pages/Dashboard.razor.css +++ b/Components/Pages/Dashboard.razor.css @@ -8,6 +8,11 @@ overflow-y: auto; } +.dashboard.expanded-mode { + padding: 0; + overflow: hidden; +} + .dashboard-header { display: flex; align-items: center; @@ -34,9 +39,42 @@ font-size: 1.1rem; } +.initializing { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + gap: 1rem; + color: rgba(255,255,255,0.6); +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid rgba(78,168,209,0.2); + border-top-color: #4ea8d1; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +.init-error { color: #f87171; } + +.retry-btn { + padding: 0.4rem 1rem; + background: rgba(78,168,209,0.2); + border: 1px solid rgba(78,168,209,0.3); + border-radius: 6px; + color: #4ea8d1; + cursor: pointer; +} +.retry-btn:hover { background: rgba(78,168,209,0.3); } + .session-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr)); gap: 1rem; margin-bottom: 1.5rem; } @@ -73,6 +111,164 @@ border-color: rgba(255,255,255,0.15); } +/* === Expanded card — mirrors Chat (Home.razor) layout === */ +.expanded-card { + display: flex; + flex-direction: column; + height: 100%; + background: #0f0f23; + color: #a0b4cc; +} + +/* Chat header (expanded) */ +.expanded-card .chat-header { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.75rem 1.5rem; + background: rgba(78,168,209,0.06); + border-bottom: 1px solid rgba(78,168,209,0.15); + flex-shrink: 0; +} +.chat-header-top { display: block; width: 100%; } +.expanded-card .chat-header h2 { margin: 0; font-size: 1.2rem; width: 100%; white-space: normal; word-break: break-word; } +.chat-header-badges { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; } +.chat-header-context { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; } +.context-badge { font-size: var(--type-caption1, 0.6rem); color: rgba(255,255,255,0.55); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 300px; } +.branch-badge { color: rgba(78,168,209,0.85); font-weight: 500; } +.model-badge { padding: 0.2rem 0.5rem; background: rgba(78,168,209,0.2); border: 1px solid rgba(78,168,209,0.3); border-radius: 4px; font-size: 0.85rem; color: #4ea8d1; line-height: 1.2; } +.processing-dot { width: 8px; height: 8px; border-radius: 50%; background: #f59e0b; box-shadow: 0 0 8px rgba(245,158,11,0.6); animation: pulse-glow 1.5s ease-in-out infinite; flex-shrink: 0; } +@keyframes pulse-glow { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } +.session-id-badge { padding: 0.2rem 0.5rem; background: rgba(251,191,36,0.2); border: 1px solid rgba(251,191,36,0.3); border-radius: 4px; font-size: 0.85rem; color: #fbbf24; font-family: monospace; cursor: pointer; } +.session-id-badge:hover { background: rgba(251,191,36,0.3); } + +/* Messages area (expanded) */ +.expanded-card .messages { + flex: 1; + overflow-y: auto; + padding: 1rem 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + min-height: 0; +} + +/* Error bar (expanded) */ +.expanded-card .error-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background: rgba(239,68,68,0.2); + border-top: 1px solid rgba(239,68,68,0.4); + color: #fca5a5; + font-size: 1rem; +} +.expanded-card .error-bar button { background: transparent; border: none; color: #fca5a5; cursor: pointer; font-size: 1.2rem; padding: 0 0.5rem; } +.expanded-card .error-bar button:hover { color: #a0b4cc; } + +/* Intent pill (expanded) */ +.expanded-card .intent-pill { + padding: 0.3rem 0.65rem; + background: rgba(139,92,246,0.12); + border: 1px solid rgba(139,92,246,0.25); + border-radius: 6px; + font-size: 0.8rem; + color: #a78bfa; + align-self: flex-start; +} + +/* Input area (expanded) */ +.expanded-card .input-area { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.5rem 0.75rem; + background: rgba(78,168,209,0.06); + border-top: 1px solid rgba(78,168,209,0.15); + flex-shrink: 0; +} +.input-row { display: flex; gap: 0.5rem; align-items: stretch; } +.expanded-card .input-area textarea { + flex: 1; + padding: 0.45rem 0.6rem; + border: 1px solid rgba(78,168,209,0.25); + border-radius: 8px; + background: rgba(78,168,209,0.15); + color: #a0b4cc; + font-size: 0.9rem; + font-family: inherit; + resize: none; + min-height: 36px; + max-height: 150px; +} +.expanded-card .input-area textarea::placeholder { color: #646e8a; } +.expanded-card .input-area textarea:focus { outline: none; border-color: #4ea8d1; } + +.send-btn { + padding: 0.45rem 0.7rem; + border: 1px solid rgba(255,255,255,0.2); + border-radius: 8px; + background: transparent; + color: rgba(255,255,255,0.6); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.send-btn:hover { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.8); } + +.stop-btn { + padding: 0.35rem 0.6rem; + background: transparent; + border: 1px solid rgba(255,255,255,0.2); + border-radius: 8px; + color: rgba(255,255,255,0.35); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} +.stop-btn:hover { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.5); } + +/* Status bar (expanded) */ +.input-status-bar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0 0.25rem; + font-size: 0.75rem; + color: rgba(200,216,240,0.35); +} +.status-sep { color: rgba(78,168,209,0.2); user-select: none; } + +.mode-switcher { display: flex; background: rgba(78,168,209,0.08); border: 1px solid rgba(78,168,209,0.15); border-radius: 6px; overflow: hidden; } +.mode-btn { all: unset; display: flex; align-items: center; gap: 0.2rem; padding: 0.1rem 0.35rem; font-size: 0.7rem; color: rgba(200,216,240,0.4); cursor: pointer; transition: all 0.15s ease; } +.mode-btn:hover { color: #8e9abd; background: rgba(78,168,209,0.1); } +.mode-btn.active { color: #4ea8d1; background: rgba(78,168,209,0.2); } + +.inline-model-select { all: unset; font-size: 0.75rem; color: rgba(78,168,209,0.6); cursor: pointer; background: transparent; border: none; padding: 0.1rem 0; } +.inline-model-select:hover { color: #4ea8d1; } +.inline-model-select option { background: #1a1e3a; color: #a0b4cc; } + +.font-size-controls { display: inline-flex; align-items: center; gap: 0.15rem; } +.font-size-btn { background: rgba(78,168,209,0.1); border: 1px solid rgba(78,168,209,0.2); border-radius: 4px; color: #8e9abd; cursor: pointer; font-size: 0.65rem; font-weight: 600; padding: 0.1rem 0.3rem; line-height: 1; } +.font-size-btn:hover:not(:disabled) { background: rgba(78,168,209,0.25); color: #c8d8f0; } +.font-size-btn:disabled { opacity: 0.3; cursor: not-allowed; } +.font-size-label { font-size: 0.65rem; color: #646e8a; min-width: 2.2em; text-align: center; cursor: pointer; padding: 0.1rem 0.15rem; border-radius: 3px; } +.font-size-label:hover { color: #c8d8f0; } + +/* Message queue (expanded) */ +.message-queue { border: 1px solid rgba(78,168,209,0.25); border-radius: 8px; background: rgba(78,168,209,0.08); overflow: hidden; } +.queue-header { display: flex; justify-content: space-between; align-items: center; padding: 0.4rem 0.75rem; font-size: 0.85rem; color: #8e9abd; border-bottom: 1px solid rgba(78,168,209,0.15); } +.queue-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.75rem; font-size: 0.9rem; border-bottom: 1px solid rgba(78,168,209,0.06); } +.queue-item:last-child { border-bottom: none; } +.queue-index { color: rgba(78,168,209,0.7); font-weight: 600; font-size: 0.8rem; min-width: 1.2rem; } +.queue-text { flex: 1; color: #c8d8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.queue-remove-btn { padding: 0.1rem 0.4rem; font-size: 0.85rem; background: transparent; border: none; color: rgba(200,216,240,0.3); cursor: pointer; border-radius: 3px; } +.queue-remove-btn:hover { background: rgba(239,68,68,0.2); color: #ef4444; } + .card-header { display: flex; align-items: center; @@ -99,6 +295,37 @@ color: #ef4444; } +.card-stop { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #ef4444; + font-size: 0.75rem; + padding: 0.15rem 0.4rem; + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; +} + +.card-stop:hover { + background: rgba(239, 68, 68, 0.3); +} + +.collapse-card-btn { + background: rgba(78,168,209,0.1); + border: 1px solid rgba(78,168,209,0.2); + color: rgba(78,168,209,0.7); + font-size: 0.7rem; + padding: 0.15rem 0.4rem; + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; +} + +.collapse-card-btn:hover { + background: rgba(78,168,209,0.2); + color: rgba(78,168,209,1); +} + .card-title { display: flex; align-items: center; @@ -113,7 +340,6 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - max-width: 250px; } .card-status-dot { @@ -142,11 +368,58 @@ } .card-model { - font-size: 0.85rem; - padding: 0.2rem 0.5rem; - background: rgba(59, 130, 246, 0.2); - border-radius: 4px; - color: #60a5fa; + font-size: var(--type-caption1, 0.6rem); + color: rgba(255,255,255,0.4); +} + +.card-context { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + padding: 0.1rem 0; +} + +.card-dir { + font-size: var(--type-caption1, 0.6rem); + color: rgba(255,255,255,0.45); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 250px; +} + +.card-branch-sub { + font-size: var(--type-caption1, 0.6rem); + color: rgba(78,168,209,0.7); +} + +.card-queue { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.5rem; + background: rgba(251,191,36,0.08); + border-radius: 6px; + font-size: 0.75rem; +} + +.queue-label { + color: rgba(251,191,36,0.8); + flex: 1; +} + +.queue-clear-btn { + background: none; + border: none; + color: rgba(255,255,255,0.4); + font-size: 0.7rem; + cursor: pointer; + padding: 0.1rem 0.3rem; +} + +.queue-clear-btn:hover { + color: #ef4444; } .card-messages { @@ -169,6 +442,23 @@ font-size: 0.95rem; } +.load-more-btn { + align-self: center; + padding: 0.3rem 0.8rem; + background: rgba(78,168,209,0.08); + border: 1px solid rgba(78,168,209,0.2); + border-radius: 6px; + color: #8e9abd; + font-size: 0.7rem; + cursor: pointer; + flex-shrink: 0; +} + +.load-more-btn:hover { + background: rgba(78,168,209,0.15); + color: rgba(255,255,255,0.85); +} + .card-msg { display: flex; gap: 0.5rem; @@ -210,7 +500,7 @@ gap: 0.5rem; } -.card-input input { +.card-input input, .card-input textarea { flex: 1; padding: 0.5rem 0.75rem; border: 1px solid rgba(255,255,255,0.2); @@ -218,13 +508,15 @@ background: rgba(255,255,255,0.08); color: #a0b4cc; font-size: 1rem; + font-family: inherit; + resize: none; } -.card-input input::placeholder { +.card-input input::placeholder, .card-input textarea::placeholder { color: rgba(255,255,255,0.3); } -.card-input input:focus { +.card-input input:focus, .card-input textarea:focus { outline: none; border-color: #3b82f6; } @@ -252,60 +544,66 @@ cursor: not-allowed; } -.broadcast-section { - margin-top: 1rem; - padding: 1rem; - background: rgba(255,255,255,0.05); - border: 1px solid rgba(255,255,255,0.1); - border-radius: 12px; +/* === Error bar === */ +.card-error { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.3rem 0.5rem; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.2); + border-radius: 6px; + color: #f87171; + font-size: 0.75rem; } -.broadcast-section h3 { - margin: 0 0 0.75rem 0; - font-size: 1.2rem; +.card-error button { + background: none; + border: none; + color: rgba(255,255,255,0.4); + cursor: pointer; + font-size: 0.7rem; + padding: 0.1rem 0.3rem; } -.broadcast-input { - display: flex; - gap: 0.75rem; +.card-error button:hover { + color: #f87171; } -.broadcast-input input { - flex: 1; - padding: 0.75rem 1rem; - border: 1px solid rgba(255,255,255,0.2); - border-radius: 8px; - background: rgba(255,255,255,0.08); - color: #a0b4cc; - font-size: 1.05rem; +/* === Intent pill === */ +.intent-pill { + padding: 0.2rem 0.6rem; + background: rgba(78,168,209,0.08); + border-radius: 12px; + color: rgba(78,168,209,0.8); + font-size: 0.75rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.broadcast-input input::placeholder { - color: rgba(255,255,255,0.3); +.intent-pill.compact { + font-size: 0.65rem; + padding: 0.1rem 0.4rem; } -.broadcast-input input:focus { - outline: none; - border-color: #3b82f6; +/* === Input area with usage === */ +.card-input-area { + display: flex; + flex-direction: column; + gap: 0.25rem; } -.broadcast-input button { - padding: 0.5rem 1.2rem; - border: 1px solid rgba(255,255,255,0.15); - border-radius: 8px; - background: rgba(255,255,255,0.06); - color: #a0b4cc; - font-size: 0.9rem; - font-weight: 500; - cursor: pointer; - white-space: nowrap; - transition: all 0.15s ease; +.usage-info { + display: flex; + gap: 0.5rem; + font-size: 0.6rem; + color: rgba(255,255,255,0.35); + padding: 0 0.25rem; } -.broadcast-input button:hover { - background: rgba(255,255,255,0.1); - border-color: rgba(255,255,255,0.25); - color: #c8d8f0; +.usage-info .usage-context { + color: rgba(255,255,255,0.25); } /* === Mobile responsive === */ @@ -321,8 +619,5 @@ .card-model-badge { font-size: 0.65rem; padding: 0.1rem 0.3rem; } .card-close { font-size: 0.75rem; } .card-messages { max-height: 180px; gap: 0.3rem; font-size: 0.8rem; } - .broadcast-section { margin-top: 0.75rem; } - .broadcast-input { gap: 0.5rem; } - .broadcast-input textarea { font-size: 0.85rem; } - .broadcast-input button { font-size: 0.85rem; padding: 0.4rem 0.8rem; } + .card-dir { max-width: 160px; } } diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor deleted file mode 100644 index a6953504f9..0000000000 --- a/Components/Pages/Home.razor +++ /dev/null @@ -1,848 +0,0 @@ -@page "/" -@using AutoPilot.App.Services -@using AutoPilot.App.Models -@inject CopilotService CopilotService -@inject IJSRuntime JS -@inject NavigationManager Nav -@inject DevTunnelService DevTunnelService -@implements IDisposable - -
- @if (!CopilotService.IsInitialized) - { -
-
-

Connecting to Copilot...

- @if (!string.IsNullOrEmpty(initError)) - { -

@initError

- - } -
- } - else if (activeSession == null) - { -
- -

Welcome to AutoPilot

-

Create a new session from the sidebar to start chatting with Copilot.

-
- } - else - { -
-

@activeSession.Name

-
- @if (activeSession.IsProcessing) - { - - } - @activeModelOverride - @if (activeSession.SessionId != null) - { - 🔗 @activeSession.SessionId[..8] 📋 - - } -
-
- -
- @if (HasMoreMessages) - { - - } - -
- - @if (!string.IsNullOrEmpty(lastError)) - { -
- ⚠️ @lastError - -
- } - -
- @if (!string.IsNullOrEmpty(currentIntent)) - { -
💭 @currentIntent
- } - @if (activeSession.MessageQueue.Any()) - { -
-
- 📋 Queued (@activeSession.MessageQueue.Count) - -
- @for (var i = 0; i < activeSession.MessageQueue.Count; i++) - { - var index = i; - var msg = activeSession.MessageQueue[i]; -
- @(index + 1) - @Truncate(msg, 80) - -
- } -
- } - @if (pendingImages.Count > 0) - { -
- @for (var i = 0; i < pendingImages.Count; i++) - { - var idx = i; - var img = pendingImages[i]; -
- @img.FileName - - @TruncateFileName(img.FileName, 15) -
- } -
- } -
- - - - @if (activeSession.IsProcessing) - { - - } - -
-
-
- - -
- · - - @if (currentUsage != null) - { - @if (currentUsage.InputTokens.HasValue || currentUsage.OutputTokens.HasValue) - { - · - ↑@FormatTokenCount(currentUsage.InputTokens ?? 0) ↓@FormatTokenCount(currentUsage.OutputTokens ?? 0) - } - @if (currentUsage.CurrentTokens.HasValue && currentUsage.TokenLimit.HasValue) - { - · - @FormatTokenCount(currentUsage.CurrentTokens.Value)/@FormatTokenCount(currentUsage.TokenLimit.Value) ctx - } - } - · - @activeSession.History.Count msgs - · - - - @(fontSize)px - - - · - -
-
- } - - @if (showDebugPanel) - { -
-
- Debug Log -
- - - - -
-
-
@FilteredDebugLog
-
- } -
- -@code { - private AgentSessionInfo? activeSession; - private string userInput = ""; - private bool isPlanMode = false; - private string streamingContent = ""; - private string currentIntent = ""; - private SessionUsageInfo? currentUsage; - private string? initError; - private string? lastError; - private string debugLog = ""; - private string debugFilter = ""; - private bool showDebugPanel = false; - private ElementReference messagesContainer; - private ElementReference textareaRef; - private ElementReference inputAreaRef; - private ElementReference fileInputRef; - private DotNetObjectReference? _dotNetRef; - private bool _needsRedirect; - private string? _redirectTo; - private bool _needsScroll = true; - private int visibleMessageCount = 50; - private List pendingImages = new(); - - private static readonly string ImageTempDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "AutoPilot", "image-attachments"); - - private record PendingImage(string FilePath, string FileName, string DataUri); - private int fontSize = 20; - - private readonly string[] inlineAvailableModels = new[] - { - "claude-opus-4.6", - "claude-sonnet-4.5", - "claude-sonnet-4", - "claude-haiku-4.5", - "gpt-5.2", - "gpt-5.1", - "gpt-5", - "gpt-5-mini", - "gemini-3-pro-preview", - }; - - private string activeModelOverride - { - get - { - // Priority: usage model (actual from SDK) > session model (if not "resumed") > fallback - var model = currentUsage?.Model; - if (string.IsNullOrEmpty(model) || !inlineAvailableModels.Contains(model)) - { - model = activeSession?.Model; - if (string.IsNullOrEmpty(model) || model == "resumed" || !inlineAvailableModels.Contains(model)) - model = _selectedModelOverride ?? inlineAvailableModels[0]; - } - return model; - } - set - { - _selectedModelOverride = value; - if (activeSession != null && activeSession.Model != "resumed") - activeSession.Model = value; - StateHasChanged(); - } - } - private string? _selectedModelOverride; - - private List VisibleHistory - { - get - { - if (activeSession == null) return new(); - try - { - var all = activeSession.History; - if (all.Count <= visibleMessageCount) return all.ToList(); - return all.Skip(all.Count - visibleMessageCount).ToList(); - } - catch (InvalidOperationException) - { - // Collection modified during enumeration — retry with snapshot - return activeSession.History.ToArray().ToList(); - } - } - } - - private bool HasMoreMessages => activeSession != null && activeSession.History.Count > visibleMessageCount; - - private void LoadMoreMessages() - { - visibleMessageCount += 50; - StateHasChanged(); - } - - protected override async Task OnInitializedAsync() - { - CopilotService.OnStateChanged += RefreshState; - CopilotService.OnContentReceived += HandleContentReceived; - CopilotService.OnSessionComplete += HandleSessionComplete; - CopilotService.OnError += HandleError; - CopilotService.OnDebug += HandleDebug; - CopilotService.OnToolStarted += HandleToolStarted; - CopilotService.OnToolCompleted += HandleToolCompleted; - CopilotService.OnReasoningReceived += HandleReasoningReceived; - CopilotService.OnReasoningComplete += HandleReasoningComplete; - CopilotService.OnIntentChanged += HandleIntentChanged; - CopilotService.OnUsageInfoChanged += HandleUsageInfoChanged; - CopilotService.OnTurnStart += HandleTurnStart; - CopilotService.OnTurnEnd += HandleTurnEnd; - - await Initialize(); - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (_needsRedirect && _redirectTo != null) - { - _needsRedirect = false; - Nav.NavigateTo(_redirectTo); - } - if (firstRender || _needsScroll) - { - _needsScroll = false; - await ForceScrollToBottom(); - } - if (firstRender && fontSize != 20) - { - try { await JS.InvokeVoidAsync("setAppFontSize", fontSize); } catch { } - } - try { await JS.InvokeVoidAsync("setupTextareaEnterHandler", textareaRef); } catch { } - if (_dotNetRef == null) - { - _dotNetRef = DotNetObjectReference.Create(this); - } - try { await JS.InvokeVoidAsync("setupTabNavigation", _dotNetRef); } catch { } - try { await JS.InvokeVoidAsync("setupImageDropZone", inputAreaRef, _dotNetRef); } catch { } - } - - private string currentToolName = ""; - private int currentTurnToolCount; - private List turnToolActivities = new(); - - private void HandleToolStarted(string sessionName, string toolName, string callId, string? inputSummary) - { - if (sessionName != CopilotService.ActiveSessionName) return; - currentToolName = toolName; - currentTurnToolCount++; - turnToolActivities.Add(new ToolActivity - { - Name = toolName, - CallId = callId, - Input = inputSummary, - StartedAt = DateTime.Now - }); - InvokeAsync(StateHasChanged); - } - - private void HandleToolCompleted(string sessionName, string callId, string result, bool success) - { - if (sessionName != CopilotService.ActiveSessionName || activeSession == null) return; - var activity = turnToolActivities.LastOrDefault(a => a.CallId == callId); - if (activity != null) - { - activity.IsComplete = true; - activity.IsSuccess = success; - activity.Result = result; - activity.CompletedAt = DateTime.Now; - } - currentToolName = ""; - InvokeAsync(StateHasChanged); - } - - private void HandleReasoningReceived(string sessionName, string reasoningId, string content) - { - // Don't add reasoning to history — it clutters the view - // The intent indicator already shows thinking state - } - - private void HandleReasoningComplete(string sessionName, string reasoningId) - { - // No-op — reasoning not tracked in history - } - - private void HandleIntentChanged(string sessionName, string intent) - { - if (sessionName != CopilotService.ActiveSessionName) return; - currentIntent = intent; - InvokeAsync(StateHasChanged); - } - - private void HandleUsageInfoChanged(string sessionName, SessionUsageInfo info) - { - if (sessionName != CopilotService.ActiveSessionName) return; - if (currentUsage == null) - { - currentUsage = info; - } - else - { - currentUsage = new SessionUsageInfo( - info.Model ?? currentUsage.Model, - info.CurrentTokens ?? currentUsage.CurrentTokens, - info.TokenLimit ?? currentUsage.TokenLimit, - info.InputTokens ?? currentUsage.InputTokens, - info.OutputTokens ?? currentUsage.OutputTokens); - } - InvokeAsync(StateHasChanged); - } - - private void HandleTurnStart(string sessionName) - { - if (sessionName != CopilotService.ActiveSessionName || activeSession == null) return; - currentTurnToolCount = 0; - currentToolName = ""; - turnToolActivities.Clear(); - InvokeAsync(StateHasChanged); - } - - private void HandleTurnEnd(string sessionName) - { - if (sessionName != CopilotService.ActiveSessionName) return; - currentIntent = ""; - currentToolName = ""; - currentTurnToolCount = 0; - // Don't clear streamingContent here — CompleteResponse hasn't added it to History yet. - // It gets cleared in HandleTurnStart when the next turn begins. - InvokeAsync(StateHasChanged); - } - - private void HandleDebug(string message) - { - debugLog = $"{DateTime.Now:HH:mm:ss} {message}\n{debugLog}"; - if (debugLog.Length > 10000) debugLog = debugLog[..10000]; - if (showDebugPanel) InvokeAsync(StateHasChanged); - } - - private string FilteredDebugLog => - string.IsNullOrWhiteSpace(debugFilter) ? debugLog : - string.Join("\n", debugLog.Split('\n') - .Where(l => l.Contains(debugFilter, StringComparison.OrdinalIgnoreCase))); - - private async Task Initialize() - { - initError = null; - try - { - await CopilotService.InitializeAsync(); - - // If no remote URL configured, redirect straight to Settings - if (CopilotService.NeedsConfiguration) - { - _needsRedirect = true; - _redirectTo = "/settings"; - return; - } - - // RestorePreviousSessionsAsync is already called inside InitializeAsync - var uiState = CopilotService.LoadUiState(); - if (uiState != null) - { - if (!string.IsNullOrEmpty(uiState.ActiveSession)) - CopilotService.SetActiveSession(uiState.ActiveSession); - if (uiState.FontSize >= 12 && uiState.FontSize <= 24) - fontSize = uiState.FontSize; - if (uiState.CurrentPage is "/dashboard" or "/settings") - { - _needsRedirect = true; - _redirectTo = uiState.CurrentPage; - } - } - - // Auto-start tunnel if previously configured - var connSettings = ConnectionSettings.Load(); - if (connSettings.AutoStartTunnel && DevTunnelService.State == TunnelState.NotStarted) - { - _ = Task.Run(async () => - { - try { await DevTunnelService.HostAsync(connSettings.Port); } - catch (Exception ex) { Console.WriteLine($"[AutoStart] Tunnel failed: {ex.Message}"); } - }); - } - } - catch (Exception ex) - { - initError = $"Failed to connect: {ex.Message}"; - } - RefreshState(); - } - - private void HandleError(string sessionName, string error) - { - if (sessionName == CopilotService.ActiveSessionName) - { - // Suppress cancellation errors (expected during app restart/abort) - if (error.Contains("cancel", StringComparison.OrdinalIgnoreCase) || - error.Contains("TaskCanceled", StringComparison.OrdinalIgnoreCase)) - return; - lastError = error; - InvokeAsync(StateHasChanged); - } - } - - private bool _forceNextScroll = false; - - private void RefreshState() - { - var prev = activeSession?.Name; - activeSession = CopilotService.GetActiveSession(); - if (activeSession?.Name != prev) - { - _needsScroll = true; - visibleMessageCount = 50; // Reset window when switching sessions - // Clear per-session UI state so previous session's state doesn't bleed - streamingContent = ""; - currentIntent = ""; - currentToolName = ""; - currentTurnToolCount = 0; - currentUsage = null; - turnToolActivities.Clear(); - } - InvokeAsync(async () => - { - StateHasChanged(); - if (_forceNextScroll) - { - _forceNextScroll = false; - await ForceScrollToBottom(); - } - else - { - await ScrollToBottom(); - } - }); - } - - private void HandleContentReceived(string sessionName, string content) - { - if (sessionName != CopilotService.ActiveSessionName || activeSession?.IsProcessing != true) - return; - - streamingContent += content; - InvokeAsync(async () => - { - StateHasChanged(); - await ScrollToBottom(); - }); - } - - private void HandleSessionComplete(string sessionName, string summary) - { - if (sessionName == CopilotService.ActiveSessionName) - { - currentIntent = ""; - streamingContent = ""; - } - InvokeAsync(async () => - { - StateHasChanged(); - try { await JS.InvokeVoidAsync("showNotification", sessionName, summary); } - catch { } - }); - } - - private string _lastKeyDebug = ""; - - private async Task HandleKeyDown(KeyboardEventArgs e) - { - _lastKeyDebug = $"Key:{e.Key} Code:{e.Code} Meta:{e.MetaKey} Ctrl:{e.CtrlKey} Shift:{e.ShiftKey}"; - - if (e.Key == "Enter" && !e.ShiftKey) - { - await SendMessage(); - await JS.InvokeVoidAsync("eval", "document.querySelector('.input-row textarea').value = ''"); - } - // Tab / Shift+Tab to cycle sessions - if (e.Key == "Tab") - { - CycleSession(e.ShiftKey); - } - // Cmd+1 through Cmd+9 to switch sessions - if (e.MetaKey && e.Key.Length == 1 && e.Key[0] >= '1' && e.Key[0] <= '9') - { - var sessions = CopilotService.GetAllSessions().ToList(); - var idx = (int)(e.Key[0] - '1'); - if (idx < sessions.Count) - { - CopilotService.SwitchSession(sessions[idx].Name); - CopilotService.SaveUiState("/", sessions[idx].Name); - StateHasChanged(); - } - } - // Cmd+Plus / Cmd+Minus to adjust font size - if (e.MetaKey && (e.Key == "=" || e.Key == "+")) IncreaseFontSize(); - if (e.MetaKey && e.Key == "-") DecreaseFontSize(); - if (e.MetaKey && e.Key == "0") ResetFontSize(); - } - - private async Task StopResponse() - { - if (activeSession == null) return; - await CopilotService.AbortSessionAsync(activeSession.Name); - streamingContent = ""; - StateHasChanged(); - } - - private async Task SendMessage() - { - if ((string.IsNullOrWhiteSpace(userInput) && pendingImages.Count == 0) || activeSession == null) - return; - - var prompt = userInput.Trim(); - - // Prepend image file paths - if (pendingImages.Count > 0) - { - var imagePaths = string.Join("\n", pendingImages.Select(img => img.FilePath)); - prompt = string.IsNullOrEmpty(prompt) - ? $"I've attached {pendingImages.Count} image(s) for you to look at:\n{imagePaths}" - : $"{imagePaths}\n\n{prompt}"; - pendingImages.Clear(); - } - - if (isPlanMode) - prompt = $"[[PLAN]] {prompt}"; - userInput = ""; - - if (activeSession.IsProcessing) - { - CopilotService.EnqueueMessage(activeSession.Name, prompt); - return; - } - - _forceNextScroll = true; - streamingContent = ""; - currentIntent = ""; - lastError = null; - StateHasChanged(); - await ForceScrollToBottom(); - - try - { - await CopilotService.SendPromptAsync(activeSession.Name, prompt); - streamingContent = ""; - } - catch (TaskCanceledException) - { - // Expected during abort or app restart — not an error - } - catch (OperationCanceledException) - { - // Expected during abort or app restart — not an error - } - catch (Exception ex) - { - lastError = $"Error: {ex.Message}"; - } - await ForceScrollToBottom(); - } - - private void RemoveQueuedMessage(int index) - { - if (activeSession != null) CopilotService.RemoveQueuedMessage(activeSession.Name, index); - } - - private void ClearQueue() - { - if (activeSession != null) CopilotService.ClearQueue(activeSession.Name); - } - - // Image attachment methods - [JSInvokable] - public void OnImageDropped(string base64Data, string fileName, string extension) - { - try - { - Directory.CreateDirectory(ImageTempDir); - var safeName = $"{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid().ToString("N")[..8]}.{extension}"; - var filePath = Path.Combine(ImageTempDir, safeName); - var bytes = Convert.FromBase64String(base64Data); - File.WriteAllBytes(filePath, bytes); - - var mime = extension.ToLowerInvariant() switch - { - "png" => "image/png", - "jpg" or "jpeg" => "image/jpeg", - "gif" => "image/gif", - "webp" => "image/webp", - "svg" => "image/svg+xml", - "bmp" => "image/bmp", - _ => "image/png" - }; - var dataUri = $"data:{mime};base64,{base64Data}"; - pendingImages.Add(new PendingImage(filePath, fileName, dataUri)); - InvokeAsync(StateHasChanged); - } - catch (Exception ex) - { - lastError = $"Failed to save image: {ex.Message}"; - InvokeAsync(StateHasChanged); - } - } - - private void RemoveAttachment(int index) - { - if (index >= 0 && index < pendingImages.Count) - { - try { File.Delete(pendingImages[index].FilePath); } catch { } - pendingImages.RemoveAt(index); - } - } - - private async Task OpenFilePicker() - { - try { await JS.InvokeVoidAsync("triggerImageFilePicker", fileInputRef); } catch { } - } - - private async Task HandleFileSelected(ChangeEventArgs e) - { - // For Blazor file input, we need to read via JS since ChangeEventArgs doesn't have file data - // Instead, use InputFile approach — read the selected files via JS - try - { - var fileData = await JS.InvokeAsync("readSelectedFiles", fileInputRef); - if (fileData != null) - { - for (var i = 0; i < fileData.Length; i += 3) - { - if (i + 2 < fileData.Length) - { - OnImageDropped(fileData[i], fileData[i + 1], fileData[i + 2]); - } - } - } - } - catch - { - // Fallback: file picker worked but we couldn't read — that's OK - } - } - - private static string TruncateFileName(string name, int maxLen) - { - if (name.Length <= maxLen) return name; - var ext = Path.GetExtension(name); - var stem = Path.GetFileNameWithoutExtension(name); - var maxStem = maxLen - ext.Length - 1; - if (maxStem < 3) return name[..maxLen]; - return stem[..maxStem] + "…" + ext; - } - - private async Task CopySessionId(string sessionId) - { - await JS.InvokeVoidAsync("eval", $"navigator.clipboard.writeText('{sessionId}')"); - } - - private void OpenSessionFolder(string sessionId) - { - var path = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".copilot", "session-state", sessionId); - if (Directory.Exists(path)) - { - System.Diagnostics.Process.Start("open", path); - } - } - - private async Task InspectDom() - { - var result = await JS.InvokeAsync("inspectMessages"); - debugLog += $"\n[DOM Inspect]\n{result}\n"; - StateHasChanged(); - } - - private string GetInputPlaceholder() - { - if (isPlanMode) return "Plan mode: describe what you want planned..."; - if (activeSession?.IsProcessing == true) return "Type to queue next message..."; - return "Message... (⏎ send, ⇧⏎ newline)"; - } - - private string Truncate(string text, int maxLen) - { - text = text.Replace("\n", " ").Replace("\r", ""); - return text.Length > maxLen ? text[..maxLen] + "…" : text; - } - - - private static string FormatTokenCount(int count) => - count >= 1000 ? $"{count / 1000}k" : count.ToString(); - - private void IncreaseFontSize() { if (fontSize < 24) { fontSize += 2; ApplyFontSize(); } } - private void DecreaseFontSize() { if (fontSize > 12) { fontSize -= 2; ApplyFontSize(); } } - private void ResetFontSize() { fontSize = 20; ApplyFontSize(); } - private void ApplyFontSize() - { - _ = JS.InvokeVoidAsync("setAppFontSize", fontSize); - CopilotService.SaveUiState("/", fontSize: fontSize); - } - - private async Task ScrollToBottom() - { - try { await JS.InvokeVoidAsync("smartScrollToBottom", messagesContainer); } - catch { } - } - - private async Task ForceScrollToBottom() - { - try { await JS.InvokeVoidAsync("scrollToBottom", messagesContainer); } - catch { } - } - - [JSInvokable] - public void CycleSession(bool reverse) - { - var sessions = CopilotService.GetAllSessions().OrderBy(s => s.Name).ToList(); - if (sessions.Count < 2) return; - - var currentName = CopilotService.ActiveSessionName; - var idx = sessions.FindIndex(s => s.Name == currentName); - if (idx < 0) idx = 0; - - idx = reverse - ? (idx - 1 + sessions.Count) % sessions.Count - : (idx + 1) % sessions.Count; - - CopilotService.SwitchSession(sessions[idx].Name); - CopilotService.SaveUiState("/", sessions[idx].Name); - streamingContent = ""; - currentToolName = ""; - currentIntent = ""; - visibleMessageCount = 50; - InvokeAsync(StateHasChanged); - } - - public void Dispose() - { - _dotNetRef?.Dispose(); - CopilotService.OnStateChanged -= RefreshState; - CopilotService.OnContentReceived -= HandleContentReceived; - CopilotService.OnSessionComplete -= HandleSessionComplete; - CopilotService.OnError -= HandleError; - CopilotService.OnDebug -= HandleDebug; - CopilotService.OnToolStarted -= HandleToolStarted; - CopilotService.OnToolCompleted -= HandleToolCompleted; - CopilotService.OnReasoningReceived -= HandleReasoningReceived; - CopilotService.OnReasoningComplete -= HandleReasoningComplete; - CopilotService.OnIntentChanged -= HandleIntentChanged; - CopilotService.OnUsageInfoChanged -= HandleUsageInfoChanged; - CopilotService.OnTurnStart -= HandleTurnStart; - CopilotService.OnTurnEnd -= HandleTurnEnd; - } -} diff --git a/Components/Pages/Home.razor.css b/Components/Pages/Home.razor.css deleted file mode 100644 index 325c0ddb90..0000000000 --- a/Components/Pages/Home.razor.css +++ /dev/null @@ -1,1062 +0,0 @@ -.chat-container { - display: flex; - flex-direction: column; - height: 100%; - background: #0f0f23; - color: #a0b4cc; -} - -.initializing, .no-session { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - gap: 1rem; - color: #c8d8f0; - font-size: 18px; -} - -.welcome-logo { - width: 120px; - height: 120px; - border-radius: 24px; - filter: drop-shadow(0 4px 24px rgba(78,168,209,0.4)); -} - -.initializing .error { color: #ef4444; } - -.initializing button { - padding: 0.5rem 1rem; - background: #4ea8d1; - border: none; - border-radius: 6px; - color: #a0b4cc; - cursor: pointer; -} - -.spinner { - width: 40px; - height: 40px; - border: 3px solid rgba(78,168,209,0.15); - border-top-color: #4ea8d1; - border-radius: 50%; - animation: spin 1s linear infinite; -} - -@keyframes spin { to { transform: rotate(360deg); } } - -.no-session h2 { margin: 0; } -.no-session p { margin: 0; color: #7a85a0; } - -/* === Inline icons === */ -.icon { vertical-align: middle; flex-shrink: 0; } -.icon.spin { animation: spin 1s linear infinite; } - -.chat-header { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem 1.5rem; - background: rgba(78,168,209,0.06); - border-bottom: 1px solid rgba(78,168,209,0.15); - flex-shrink: 0; - min-width: 0; -} - -.chat-header h2 { - margin: 0; - font-size: 1.2rem; - min-width: 0; - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.chat-header-badges { - display: flex; - align-items: center; - gap: 0.5rem; - flex-shrink: 0; -} - -.folder-btn { - background: rgba(78,168,209,0.1); - border: 1px solid rgba(78,168,209,0.2); - border-radius: 6px; - color: #8e9abd; - cursor: pointer; - padding: 0.2rem 0.4rem; - display: flex; - align-items: center; - transition: all 0.15s; -} - -.folder-btn:hover { - background: rgba(78,168,209,0.25); - color: #a0b4cc; -} - -.session-nav-btn { - background: rgba(78,168,209,0.1); - border: 1px solid rgba(78,168,209,0.2); - border-radius: 6px; - color: #8e9abd; - cursor: pointer; - font-size: 0.9rem; - padding: 0.25rem 0.5rem; - transition: all 0.15s; -} -.session-nav-btn:hover { - background: rgba(78,168,209,0.3); - color: #a0b4cc; - border-color: rgba(78,168,209,0.5); -} - -.model-badge { - padding: 0.2rem 0.5rem; - background: rgba(78, 168, 209, 0.2); - border: 1px solid rgba(78, 168, 209, 0.3); - border-radius: 4px; - font-size: 0.85rem; - color: #4ea8d1; - line-height: 1.2; -} - -.processing-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: #f59e0b; - box-shadow: 0 0 8px rgba(245,158,11,0.6); - animation: pulse-glow 1.5s ease-in-out infinite; - flex-shrink: 0; -} - -@keyframes pulse-glow { - 0%, 100% { opacity: 1; box-shadow: 0 0 8px rgba(245,158,11,0.6); } - 50% { opacity: 0.4; box-shadow: 0 0 4px rgba(245,158,11,0.3); } -} - -.messages { - flex: 1; - overflow-y: auto; - padding: 1rem; - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.empty-chat { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - color: #7a85a0; -} - -.load-more-btn { - align-self: center; - padding: 0.4rem 1rem; - background: rgba(78,168,209,0.08); - border: 1px solid rgba(78,168,209,0.2); - border-radius: 6px; - color: #8e9abd; - font-size: 0.8rem; - cursor: pointer; - flex-shrink: 0; - display: flex; - align-items: center; - gap: 0.4rem; -} - -.load-more-btn:hover { - background: rgba(78,168,209,0.15); - color: rgba(255,255,255,0.85); -} - -/* === Messages === */ -.message { - display: flex; - gap: 0.75rem; - max-width: 85%; - flex-shrink: 0; -} - -.message.user { - align-self: flex-end; - flex-direction: row-reverse; -} - -.message.system { - align-self: center; - max-width: 100%; - justify-content: center; -} - -.system-text { - font-size: 0.8rem; - color: #646e8a; - text-align: center; - padding: 0.25rem 0; -} - -.message-avatar { - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - background: rgba(78,168,209,0.15); - border-radius: 50%; - font-size: 1.2rem; - flex-shrink: 0; -} - -.message.user .message-avatar { background: rgba(78, 168, 209, 0.3); } - -.message-content { - display: flex; - flex-direction: column; - gap: 0.25rem; - min-width: 0; - flex: 1; -} - -.message-text { - padding: 0.75rem 1rem; - border-radius: 12px; - line-height: 1.6; - font-size: 1.05rem; - word-break: break-word; - min-height: 1.6em; -} - -.message.user .message-text { - background: #4ea8d1; - border-bottom-right-radius: 4px; -} - -.message.assistant .message-text { - background: rgba(78,168,209,0.15); - border-bottom-left-radius: 4px; - color: #a0b4cc; -} - -.message.streaming .message-text::after { - content: "▋"; - animation: blink 1s infinite; -} - -@keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } } - -.message-time { - font-size: 0.75rem; - color: rgba(200,216,240,0.35); - padding: 0 0.5rem; -} - -.message.user .message-time { text-align: right; } - -/* === Markdown body (::deep for MarkupString inner content) === */ -::deep .markdown-body { color: #a0b4cc; } -::deep .markdown-body p { margin: 0 0 0.5rem 0; } -::deep .markdown-body p:last-child { margin-bottom: 0; } - -::deep .markdown-body pre { - background: rgba(0,0,0,0.4); - padding: 0.75rem; - border-radius: 6px; - overflow-x: auto; - margin: 0.5rem 0; -} - -::deep .markdown-body code { - font-family: 'SF Mono', Monaco, Consolas, monospace; - font-size: 0.9rem; -} - -::deep .markdown-body :not(pre) > code { - background: rgba(0,0,0,0.35); - padding: 0.15rem 0.35rem; - border-radius: 3px; -} - -::deep .markdown-body ul, ::deep .markdown-body ol { - margin: 0.4rem 0; - padding-left: 1.5rem; -} - -::deep .markdown-body li { margin: 0.15rem 0; } - -::deep .markdown-body h1, ::deep .markdown-body h2, ::deep .markdown-body h3 { - margin: 0.75rem 0 0.4rem 0; -} -::deep .markdown-body h1 { font-size: 1.25em; } -::deep .markdown-body h2 { font-size: 1.15em; } -::deep .markdown-body h3 { font-size: 1.05em; } - -::deep .markdown-body blockquote { - border-left: 3px solid #4ea8d1; - margin: 0.5rem 0; - padding: 0.4rem 0.75rem; - background: rgba(78, 168, 209, 0.08); - border-radius: 0 6px 6px 0; -} - -::deep .markdown-body a { color: #4ea8d1; text-decoration: none; } -::deep .markdown-body a:hover { text-decoration: underline; } - -::deep .markdown-body table { - border-collapse: collapse; - margin: 0.5rem 0; - width: 100%; - font-size: 0.9rem; -} - -::deep .markdown-body th, ::deep .markdown-body td { - border: 1px solid rgba(78,168,209,0.2); - padding: 0.4rem 0.6rem; -} - -::deep .markdown-body th { - background: rgba(78,168,209,0.1); - font-weight: 600; -} - -::deep .markdown-body img { - max-width: 100%; - height: auto; - border-radius: 8px; - margin: 0.5rem 0; - display: block; -} - -::deep .markdown-body p > img:only-child { - margin: 0; -} - -::deep .markdown-body video { - max-width: 100%; - border-radius: 8px; - margin: 0.5rem 0; -} - -/* === Reasoning blocks === */ -.reasoning-block { - background: rgba(139, 92, 246, 0.08); - border: 1px solid rgba(139, 92, 246, 0.25); - border-radius: 8px; - min-width: 0; - align-self: stretch; - flex-shrink: 0; - overflow: hidden; -} - -.reasoning-header { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 0.5rem 0.75rem; - background: transparent; - border: none; - cursor: pointer; - color: #a0b4cc; - font-size: 0.85rem; -} - -.reasoning-header:hover { background: rgba(139, 92, 246, 0.12); } - -.reasoning-title { - font-weight: 600; - color: #a78bfa; -} - -.collapse-icon { - font-size: 0.7rem; - color: #646e8a; -} - -.reasoning-content { - padding: 0 0.75rem 0.75rem 0.75rem; - font-size: 0.85rem; - line-height: 1.5; - color: #8e9abd; - font-style: italic; - white-space: pre-wrap; - word-break: break-word; - max-height: 200px; - overflow-y: auto; -} - -.reasoning-content.preview { - max-height: none; - overflow: hidden; - position: relative; - mask-image: linear-gradient(to bottom, black 50%, transparent 100%); - -webkit-mask-image: linear-gradient(to bottom, black 50%, transparent 100%); -} - -.reasoning-block.collapsed .reasoning-content:not(.preview) { display: none; } - -/* === Tool summary (grouped completed tools) === */ -.tool-summary { - display: flex; - align-items: center; - gap: 0.4rem; - padding: 0.25rem 0.75rem; - font-size: 0.8rem; - color: #646e8a; - max-width: 90%; -} - -.tool-summary.has-error { color: rgba(248, 113, 113, 0.6); } - -/* === Tool cards === */ -.tool-card { - border-radius: 8px; - border: 1px solid rgba(78,168,209,0.2); - min-width: 0; - align-self: stretch; - flex-shrink: 0; - overflow: hidden; - background: rgba(78,168,209,0.05); -} - -.tool-card.running { border-color: rgba(78, 168, 209, 0.4); } -.tool-card.success { border-color: rgba(34, 197, 94, 0.35); } -.tool-card.error { border-color: rgba(239, 68, 68, 0.35); } - -.tool-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.45rem 0.75rem; - font-size: 0.85rem; -} - -.tool-info { - font-weight: 600; - color: #c8d8f0; -} - -.tool-status { font-size: 0.8rem; color: #7a85a0; } -.tool-card.running .tool-status { color: #4ea8d1; } -.tool-card.success .tool-status { color: #00c35b; } -.tool-card.error .tool-status { color: #f87171; } - -.tool-result-section { border-top: 1px solid rgba(78,168,209,0.1); } - -.tool-result-toggle { - width: 100%; - padding: 0.35rem 0.75rem; - background: transparent; - border: none; - cursor: pointer; - font-size: 0.75rem; - color: #646e8a; - text-align: left; -} - -.tool-result-toggle:hover { background: rgba(78,168,209,0.06); } - -.tool-result-content { - margin: 0; - padding: 0.5rem 0.75rem; - font-size: 0.8rem; - background: rgba(0,0,0,0.2); - color: #8e9abd; - white-space: pre-wrap; - word-break: break-word; - overflow-wrap: break-word; - max-height: 200px; - overflow-y: auto; - overflow-x: hidden; - font-family: 'SF Mono', Monaco, Consolas, monospace; -} - -.tool-result-content.preview { - max-height: none; - overflow: hidden; - mask-image: linear-gradient(to bottom, black 50%, transparent 100%); - -webkit-mask-image: linear-gradient(to bottom, black 50%, transparent 100%); -} - -.tool-image-result { - padding: 0.5rem; - border-top: 1px solid rgba(78,168,209,0.1); -} - -.tool-image-result img { - max-width: 100%; - height: auto; - border-radius: 6px; - display: block; -} - -/* === Task complete card === */ -.task-complete-card { - display: flex; - align-items: center; - gap: 0.4rem; - padding: 0.35rem 0.7rem; - background: rgba(34, 197, 94, 0.08); - border: 1px solid rgba(34, 197, 94, 0.25); - border-radius: 6px; - font-size: 0.85rem; - min-width: 0; - align-self: stretch; - flex-shrink: 0; -} - -.task-complete-text { - color: #86efac; - line-height: 1.4; -} - -/* === Error cards === */ -.error-card { - display: flex; - align-items: flex-start; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - background: rgba(239, 68, 68, 0.1); - border: 1px solid rgba(239, 68, 68, 0.3); - border-radius: 8px; - font-size: 0.9rem; - min-width: 0; - align-self: stretch; - flex-shrink: 0; -} - -.error-icon { flex-shrink: 0; } -.error-text { color: #fca5a5; line-height: 1.4; } - -/* === Intent pill === */ -.intent-pill { - padding: 0.3rem 0.65rem; - background: rgba(139, 92, 246, 0.12); - border: 1px solid rgba(139, 92, 246, 0.25); - border-radius: 6px; - font-size: 0.8rem; - color: #a78bfa; - align-self: flex-start; -} - -/* === Input status bar === */ -.input-status-bar { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0 0.25rem; - padding-bottom: var(--nav-bar-height, 0px); - font-size: 0.75rem; - color: rgba(200,216,240,0.35); -} - -.plan-icon-toggle { - all: unset; - display: flex; - align-items: center; - gap: 0.3rem; - color: rgba(200,216,240,0.35); - font-size: 0.75rem; - cursor: pointer; - transition: color 0.15s ease; -} - -.plan-icon-toggle:hover { - color: #8e9abd; -} - -.plan-icon-toggle.active { - color: #4ea8d1; -} - -.mode-switcher { - display: flex; - background: rgba(78,168,209,0.08); - border: 1px solid rgba(78,168,209,0.15); - border-radius: 6px; - overflow: hidden; -} - -.mode-btn { - all: unset; - display: flex; - align-items: center; - gap: 0.2rem; - padding: 0.1rem 0.35rem; - font-size: 0.7rem; - color: rgba(200,216,240,0.4); - cursor: pointer; - transition: all 0.15s ease; -} - -.mode-btn svg { - width: 11px; - height: 11px; -} - -.mode-btn:hover { - color: #8e9abd; - background: rgba(78,168,209,0.1); -} - -.mode-btn.active { - color: #4ea8d1; - background: rgba(78,168,209,0.2); -} - -.inline-model-select { - all: unset; - font-size: 0.75rem; - color: rgba(78, 168, 209, 0.6); - cursor: pointer; - background: transparent; - border: none; - padding: 0.1rem 0; -} - -.inline-model-select:hover { - color: #4ea8d1; -} - -.inline-model-select option { - background: #1a1e3a; - color: #a0b4cc; -} -.status-tokens { } -.status-ctx { } -.status-msgs { } -.status-sep { color: rgba(78,168,209,0.2); user-select: none; } - -/* === Font size controls === */ -.font-size-controls { - display: inline-flex; - align-items: center; - gap: 0.15rem; -} - -.font-size-btn { - background: rgba(78,168,209,0.1); - border: 1px solid rgba(78,168,209,0.2); - border-radius: 4px; - color: #8e9abd; - cursor: pointer; - font-size: 0.65rem; - font-weight: 600; - padding: 0.1rem 0.3rem; - line-height: 1; - transition: all 0.15s; -} - -.font-size-btn:hover:not(:disabled) { - background: rgba(78,168,209,0.25); - color: #c8d8f0; - border-color: rgba(78,168,209,0.4); -} - -.font-size-btn:disabled { - opacity: 0.3; - cursor: not-allowed; -} - -.font-size-label { - font-size: 0.65rem; - color: #646e8a; - min-width: 2.2em; - text-align: center; - cursor: pointer; - padding: 0.1rem 0.15rem; - border-radius: 3px; - transition: color 0.15s; -} - -.font-size-label:hover { - color: #c8d8f0; -} - -/* === Input area === */ -.input-area { - display: flex; - flex-direction: column; - gap: 0.4rem; - padding: 0.5rem 0.75rem; - padding-bottom: calc(0.5rem + var(--nav-bar-height, 0px)); - background: rgba(78,168,209,0.06); - border-top: 1px solid rgba(78,168,209,0.15); -} - -.input-row { display: flex; gap: 0.5rem; align-items: stretch; } - -.input-area textarea { - flex: 1; - padding: 0.45rem 0.6rem; - border: 1px solid rgba(78,168,209,0.25); - border-radius: 8px; - background: rgba(78,168,209,0.15); - color: #a0b4cc; - font-size: 0.9rem; - font-family: inherit; - resize: none; - min-height: 36px; - max-height: 150px; -} - -.input-area textarea::placeholder { color: #646e8a; } -.input-area textarea:focus { outline: none; border-color: #4ea8d1; } -.input-area textarea:disabled { opacity: 0.6; } - -.input-area .input-row button.send-btn { - padding: 0.45rem 0.7rem; - border: 1px solid rgba(255,255,255,0.2); - border-radius: 8px; - background: transparent; - color: rgba(255,255,255,0.6); - font-size: 0.9rem; - font-weight: 500; - cursor: pointer; - transition: background 0.15s ease; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.input-area .input-row button.send-btn:hover:not(:disabled) { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.8); } -.input-area .input-row button.send-btn:disabled { opacity: 0.3; cursor: not-allowed; } - -.input-area .input-row .stop-btn { - padding: 0.35rem 0.6rem; - background: transparent; - border: 1px solid rgba(255,255,255,0.2); - border-radius: 8px; - min-width: auto; - color: rgba(255,255,255,0.35); - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; -} -.input-area .input-row .stop-btn:hover { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.5); border-color: rgba(255,255,255,0.3); } - -/* === Drag & drop / image attachments === */ -.input-area.drag-over { - background: rgba(59, 130, 246, 0.15); - border-color: rgba(59, 130, 246, 0.5); - box-shadow: inset 0 0 0 2px rgba(59, 130, 246, 0.3); -} - -.attach-btn { - padding: 0.45rem 0.5rem; - border: none; - border-radius: 8px; - background: rgba(255,255,255,0.08); - color: rgba(255,255,255,0.6); - cursor: pointer; - transition: all 0.15s ease; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} -.attach-btn:hover { - background: rgba(59, 130, 246, 0.3); - color: white; -} - -.attachment-strip { - display: flex; - gap: 0.5rem; - padding: 0.25rem 0; - overflow-x: auto; - scrollbar-width: thin; -} - -.attachment-thumb { - position: relative; - flex-shrink: 0; - border-radius: 8px; - overflow: hidden; - border: 1px solid rgba(255,255,255,0.15); - background: rgba(255,255,255,0.05); - width: 80px; - display: flex; - flex-direction: column; - align-items: center; -} - -.attachment-thumb img { - width: 80px; - height: 60px; - object-fit: cover; - display: block; -} - -.attachment-thumb .attachment-name { - font-size: 0.65rem; - color: rgba(255,255,255,0.5); - padding: 2px 4px; - text-align: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - width: 100%; -} - -.attachment-remove { - position: absolute; - top: 2px; - right: 2px; - width: 18px; - height: 18px; - border-radius: 50%; - background: rgba(0,0,0,0.7); - border: none; - color: white; - font-size: 12px; - line-height: 1; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: opacity 0.15s; -} - -.attachment-thumb:hover .attachment-remove { - opacity: 1; -} - -/* === Error bar === */ -.error-bar { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.75rem 1rem; - background: rgba(239, 68, 68, 0.2); - border-top: 1px solid rgba(239, 68, 68, 0.4); - color: #fca5a5; - font-size: 1rem; -} - -.error-bar button { - background: transparent; - border: none; - color: #fca5a5; - cursor: pointer; - font-size: 1.2rem; - padding: 0 0.5rem; -} - -.error-bar button:hover { color: #a0b4cc; } - -/* === Badges === */ -.session-id-badge { - padding: 0.2rem 0.5rem; - background: rgba(251, 191, 36, 0.2); - border: 1px solid rgba(251, 191, 36, 0.3); - border-radius: 4px; - font-size: 0.85rem; - color: #fbbf24; - font-family: monospace; - cursor: pointer; - transition: background 0.15s ease; - line-height: 1.2; -} - -.session-id-badge:hover { background: rgba(251, 191, 36, 0.3); } - -.copy-icon { opacity: 0.5; } -.session-id-badge:hover .copy-icon { opacity: 1; } - -/* === Debug panel === */ -.debug-panel { - position: fixed; - bottom: 80px; - left: 10px; - right: 10px; - max-height: 300px; - background: rgba(0,0,0,0.92); - border: 1px solid #333; - border-radius: 8px; - font-size: 0.8rem; - z-index: 1000; - overflow: hidden; - display: flex; - flex-direction: column; -} - -.debug-header { - padding: 0.4rem 0.6rem; - background: #222; - display: flex; - justify-content: space-between; - align-items: center; - font-weight: 600; - font-size: 0.75rem; - flex-shrink: 0; -} - -.debug-actions { - display: flex; - align-items: center; - gap: 0.35rem; -} - -.debug-filter { - padding: 0.15rem 0.4rem; - font-size: 0.7rem; - background: #333; - border: 1px solid #555; - border-radius: 4px; - color: #a0b4cc; - width: 120px; -} - -.debug-filter::placeholder { color: rgba(200,216,240,0.35); } -.debug-filter:focus { outline: none; border-color: #4ea8d1; } - -.debug-header button { - padding: 0.15rem 0.4rem; - font-size: 0.65rem; - background: #444; - border: none; - color: #a0b4cc; - border-radius: 4px; - cursor: pointer; -} - -.debug-header button:hover { background: #555; } - -.debug-panel pre { - margin: 0; - padding: 0.5rem; - flex: 1; - overflow-y: auto; - font-family: 'SF Mono', Monaco, Consolas, monospace; - font-size: 0.7rem; - color: #0f0; - white-space: pre-wrap; - word-break: break-all; -} - -/* === Message queue === */ -.message-queue { - border: 1px solid rgba(78, 168, 209, 0.25); - border-radius: 8px; - background: rgba(78, 168, 209, 0.08); - overflow: hidden; -} - -.queue-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.4rem 0.75rem; - font-size: 0.85rem; - color: #8e9abd; - border-bottom: 1px solid rgba(78, 168, 209, 0.15); -} - -.queue-clear-btn { - padding: 0.15rem 0.5rem !important; - font-size: 0.75rem !important; - background: rgba(239, 68, 68, 0.2) !important; - border: 1px solid rgba(239, 68, 68, 0.3) !important; - border-radius: 4px !important; - color: #fca5a5 !important; - cursor: pointer; -} - -.queue-clear-btn:hover { background: rgba(239, 68, 68, 0.35) !important; } - -.queue-item { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.35rem 0.75rem; - font-size: 0.9rem; - border-bottom: 1px solid rgba(78,168,209,0.06); -} - -.queue-item:last-child { border-bottom: none; } - -.queue-index { - color: rgba(78, 168, 209, 0.7); - font-weight: 600; - font-size: 0.8rem; - min-width: 1.2rem; -} - -.queue-text { - flex: 1; - color: #c8d8f0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.queue-remove-btn { - padding: 0.1rem 0.4rem !important; - font-size: 0.85rem !important; - background: transparent !important; - border: none !important; - color: rgba(200,216,240,0.3) !important; - cursor: pointer; - border-radius: 3px !important; -} - -.queue-remove-btn:hover { - background: rgba(239, 68, 68, 0.2) !important; - color: #ef4444 !important; -} - -/* === Mobile responsive === */ -@media (max-width: 640px) { - .chat-header { - padding: 0.5rem 0.75rem; - gap: 0.5rem; - } - .chat-header h2 { font-size: 1rem; } - .model-badge { font-size: 0.7rem; padding: 0.15rem 0.35rem; } - .session-id-badge { font-size: 0.65rem; padding: 0.15rem 0.35rem; } - .resumed-badge { font-size: 0.55rem; } - - .messages { - padding: 0.5rem; - gap: 0.5rem; - } - .message { max-width: 95%; gap: 0.4rem; } - .message-avatar { width: 28px; height: 28px; font-size: 0.9rem; } - .message-text { padding: 0.5rem 0.65rem; font-size: 0.85rem; line-height: 1.5; } - .message-time { font-size: 0.65rem; } - - ::deep .markdown-body pre { padding: 0.5rem; font-size: 0.75rem; } - ::deep .markdown-body code { font-size: 0.8em; padding: 0.1em 0.25em; } - - .reasoning-header { padding: 0.3rem 0.6rem; font-size: 0.75rem; } - .reasoning-content { padding: 0.4rem 0.6rem; font-size: 0.8rem; } - - .tool-card { min-width: 200px; } - .tool-header { padding: 0.3rem 0.6rem; font-size: 0.75rem; } - .tool-status { font-size: 0.7rem; } - .tool-result-content { padding: 0.4rem 0.6rem; font-size: 0.7rem; } - .tool-result-toggle { padding: 0.25rem 0.6rem; font-size: 0.65rem; } - - .task-complete-card { font-size: 0.75rem; padding: 0.25rem 0.5rem; } - .error-card { font-size: 0.8rem; padding: 0.4rem 0.6rem; } - .intent-pill { font-size: 0.7rem; padding: 0.2rem 0.5rem; } - - .input-area { padding: 0.4rem 0.5rem; padding-bottom: calc(0.5rem + var(--nav-bar-height, 0px)); gap: 0.3rem; } - .input-area textarea { font-size: 0.85rem; min-height: 32px; padding: 0.35rem 0.5rem; } - .input-area .input-row button { padding: 0.35rem 0.6rem; } - .input-status-bar { font-size: 0.65rem; gap: 0.5rem; } - .plan-icon-toggle { font-size: 0.65rem; } - - .queue-bar { padding: 0.3rem 0.5rem; font-size: 0.7rem; } - .error-bar { font-size: 0.75rem; padding: 0.3rem 0.5rem; } -} diff --git a/Components/Pages/Settings.razor b/Components/Pages/Settings.razor index 67fda70de1..e69a5c9f13 100644 --- a/Components/Pages/Settings.razor +++ b/Components/Pages/Settings.razor @@ -188,12 +188,30 @@
    @if (PlatformHelper.IsDesktop) { -
  • Embedded — Simple, default. Copilot process dies when app closes. All sessions are lost.
  • -
  • Persistent — App spawns a detached Copilot server on a port. Survives app restarts — sessions reconnect after relaunching.
  • +
  • Embedded — Simple. Copilot process dies when app closes. All sessions are lost.
  • +
  • Persistent — Default. App spawns a detached Copilot server on a port. Survives app restarts — sessions reconnect after relaunching.
  • }
  • Remote — Connect to a Copilot server on another machine via a DevTunnel URL. Use this from iOS/Android.
+ + @if (PlatformHelper.IsDesktop) + { +
+

⌨️ Keyboard Shortcuts

+ + + + + + + + + + +
ShortcutAction
⌘E / Ctrl+EExpand focused session / collapse back to grid
EscapeCollapse expanded session back to grid
TabFocus next session input (grid) or switch to next session (expanded)
Shift+TabFocus previous session input (grid) or switch to previous session (expanded)
EnterSend message (or queue if session is busy)
A− / A+Decrease / increase font size (in expanded view status bar)
+
+ }
@code { @@ -272,8 +290,8 @@ private string GetModeDescription(ConnectionMode mode) => mode switch { - ConnectionMode.Embedded => "Default. Copilot process dies when app closes.", - ConnectionMode.Persistent => "Copilot server survives app restarts. Sessions persist.", + ConnectionMode.Embedded => "Copilot process dies when app closes.", + ConnectionMode.Persistent => "Default. Copilot server survives app restarts. Sessions persist.", ConnectionMode.Remote => "Connect to a remote server via DevTunnel URL.", _ => "" }; diff --git a/Components/Pages/Settings.razor.css b/Components/Pages/Settings.razor.css index 345355e3cf..543b47855b 100644 --- a/Components/Pages/Settings.razor.css +++ b/Components/Pages/Settings.razor.css @@ -520,3 +520,37 @@ .icon { vertical-align: middle; } .icon.spin { animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } + +/* === Hotkey table === */ +.hotkey-table { + width: 100%; + border-collapse: collapse; + margin-top: 0.5rem; + font-size: 0.85rem; +} + +.hotkey-table th { + text-align: left; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid rgba(78,168,209,0.2); + color: rgba(255,255,255,0.5); + font-weight: 500; +} + +.hotkey-table td { + padding: 0.4rem 0.6rem; + border-bottom: 1px solid rgba(255,255,255,0.05); + color: #a0b4cc; +} + +.hotkey-table kbd { + display: inline-block; + padding: 0.15rem 0.4rem; + background: rgba(78,168,209,0.15); + border: 1px solid rgba(78,168,209,0.3); + border-radius: 4px; + font-family: -apple-system, 'SF Mono', monospace; + font-size: 0.75rem; + color: #4ea8d1; + line-height: 1.3; +} diff --git a/MauiProgram.cs b/MauiProgram.cs index 796a8e078f..2fdc31c9e9 100644 --- a/MauiProgram.cs +++ b/MauiProgram.cs @@ -12,11 +12,27 @@ namespace AutoPilot.App; public static class MauiProgram { - private static readonly string CrashLogPath = Path.Combine( - string.IsNullOrEmpty(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)) - ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) - : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".copilot", "autopilot-crash.log"); + private static string? _crashLogPath; + private static string CrashLogPath => _crashLogPath ??= GetCrashLogPath(); + + private static string GetCrashLogPath() + { + try + { +#if ANDROID || IOS + return Path.Combine(FileSystem.AppDataDirectory, ".copilot", "autopilot-crash.log"); +#else + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(home)) + home = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(home, ".copilot", "autopilot-crash.log"); +#endif + } + catch + { + return Path.Combine(Path.GetTempPath(), ".copilot", "autopilot-crash.log"); + } + } public static MauiApp CreateMauiApp() { diff --git a/Models/AgentSessionInfo.cs b/Models/AgentSessionInfo.cs index bb7f614d30..dc6533942f 100644 --- a/Models/AgentSessionInfo.cs +++ b/Models/AgentSessionInfo.cs @@ -11,6 +11,7 @@ public class AgentSessionInfo public List MessageQueue { get; } = new(); public string? WorkingDirectory { get; set; } + public string? GitBranch { get; set; } // For resumed sessions public string? SessionId { get; set; } diff --git a/Models/BridgeMessages.cs b/Models/BridgeMessages.cs index cbc83e6fcf..db08644350 100644 --- a/Models/BridgeMessages.cs +++ b/Models/BridgeMessages.cs @@ -75,6 +75,7 @@ public static class BridgeMessageTypes public const string ResumeSession = "resume_session"; public const string SwitchSession = "switch_session"; public const string QueueMessage = "queue_message"; + public const string CloseSession = "close_session"; } // --- Server → Client payloads --- diff --git a/Models/ConnectionSettings.cs b/Models/ConnectionSettings.cs index 581c092604..9a1656ad96 100644 --- a/Models/ConnectionSettings.cs +++ b/Models/ConnectionSettings.cs @@ -5,7 +5,7 @@ namespace AutoPilot.App.Models; public enum ConnectionMode { - Embedded, // SDK spawns copilot via stdio (default, dies with app) + Embedded, // SDK spawns copilot via stdio (dies with app) Persistent, // App spawns detached copilot server; survives app restarts Remote // Connect to a remote server via URL (e.g. DevTunnel) } @@ -26,15 +26,30 @@ public class ConnectionSettings ? RemoteUrl : $"{Host}:{Port}"; - private static readonly string SettingsPath = Path.Combine( + private static string? _settingsPath; + private static string SettingsPath => _settingsPath ??= Path.Combine( GetCopilotDir(), "autopilot-settings.json"); private static string GetCopilotDir() { +#if IOS || ANDROID + try + { + return Path.Combine(FileSystem.AppDataDirectory, ".copilot"); + } + catch + { + var fallback = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrEmpty(fallback)) + fallback = Path.GetTempPath(); + return Path.Combine(fallback, ".copilot"); + } +#else var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); if (string.IsNullOrEmpty(home)) home = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); return Path.Combine(home, ".copilot"); +#endif } public static ConnectionSettings Load() diff --git a/Models/PlatformHelper.cs b/Models/PlatformHelper.cs index c7885334ff..f6686c8375 100644 --- a/Models/PlatformHelper.cs +++ b/Models/PlatformHelper.cs @@ -21,6 +21,6 @@ public static class PlatformHelper : [ConnectionMode.Remote]; public static ConnectionMode DefaultMode => IsDesktop - ? ConnectionMode.Embedded + ? ConnectionMode.Persistent : ConnectionMode.Remote; } diff --git a/Services/CopilotService.cs b/Services/CopilotService.cs index 011f7606b1..277daad233 100644 --- a/Services/CopilotService.cs +++ b/Services/CopilotService.cs @@ -51,9 +51,11 @@ private static string GetCopilotBaseDir() private static string? _activeSessionsFile; private static string ActiveSessionsFile => _activeSessionsFile ??= Path.Combine(CopilotBaseDir, "autopilot-active-sessions.json"); - private static readonly string SessionAliasesFile = Path.Combine(CopilotBaseDir, "autopilot-session-aliases.json"); + private static string? _sessionAliasesFile; + private static string SessionAliasesFile => _sessionAliasesFile ??= Path.Combine(CopilotBaseDir, "autopilot-session-aliases.json"); - private static readonly string UiStateFile = Path.Combine(CopilotBaseDir, "autopilot-ui-state.json"); + private static string? _uiStateFile; + private static string UiStateFile => _uiStateFile ??= Path.Combine(CopilotBaseDir, "autopilot-ui-state.json"); private static string? _projectDir; private static string ProjectDir => _projectDir ??= FindProjectDir(); @@ -122,6 +124,7 @@ private class SessionState public TaskCompletionSource? ResponseCompletion { get; set; } public StringBuilder CurrentResponse { get; } = new(); public bool HasReceivedDeltasThisTurn { get; set; } + public bool HasReceivedEventsSinceResume { get; set; } public string? LastMessageId { get; set; } } @@ -328,6 +331,7 @@ private void SyncRemoteSessions() CreatedAt = rs.CreatedAt, SessionId = rs.SessionId, WorkingDirectory = rs.WorkingDirectory, + GitBranch = GetGitBranch(rs.WorkingDirectory), }; _sessions[rs.Name] = new SessionState { @@ -795,8 +799,10 @@ public async Task ResumeSessionAsync(string sessionId, string Model = "resumed", // Model info may not be immediately available CreatedAt = DateTime.Now, SessionId = sessionId, - IsResumed = true + IsResumed = true, + WorkingDirectory = GetSessionWorkingDirectory(sessionId) }; + info.GitBranch = GetGitBranch(info.WorkingDirectory); // Add loaded history to the session info foreach (var msg in history) @@ -839,10 +845,24 @@ public async Task ResumeSessionAsync(string sessionId, string }; // If still processing, set up ResponseCompletion so events flow properly + // but add a timeout — if no new events arrive, the old turn is gone if (isStillProcessing) { state.ResponseCompletion = new TaskCompletionSource(); Debug($"Session '{displayName}' is still processing (was mid-turn when app restarted)"); + + _ = Task.Run(async () => + { + await Task.Delay(TimeSpan.FromSeconds(10)); + if (state.Info.IsProcessing && !state.HasReceivedEventsSinceResume) + { + Debug($"Session '{displayName}' processing timeout — no new events after resume, clearing stale state"); + state.Info.IsProcessing = false; + state.ResponseCompletion?.TrySetResult("timeout"); + state.Info.History.Add(ChatMessage.SystemMessage("⏹ Previous turn appears to have ended. Ready for new input.")); + InvokeOnUI(() => OnStateChanged?.Invoke()); + } + }); } copilotSession.On(evt => HandleSessionEvent(state, evt)); @@ -921,7 +941,8 @@ ALWAYS run the relaunch script as the final step after making changes to this pr Model = sessionModel, CreatedAt = DateTime.Now, SessionId = copilotSession.SessionId, - WorkingDirectory = sessionDir + WorkingDirectory = sessionDir, + GitBranch = GetGitBranch(sessionDir) }; Debug($"Session '{name}' created with ID: {copilotSession.SessionId}"); @@ -954,6 +975,7 @@ ALWAYS run the relaunch script as the final step after making changes to this pr private void HandleSessionEvent(SessionState state, SessionEvent evt) { + state.HasReceivedEventsSinceResume = true; var sessionName = state.Info.Name; void Invoke(Action action) { @@ -1079,6 +1101,8 @@ void Invoke(Action action) case SessionIdleEvent: CompleteResponse(state); + // Refresh git branch — agent may have switched branches + state.Info.GitBranch = GetGitBranch(state.Info.WorkingDirectory); break; case SessionStartEvent start: @@ -1113,8 +1137,9 @@ void Invoke(Action action) break; case SessionErrorEvent err: - Invoke(() => OnError?.Invoke(sessionName, err.Data.Message)); - state.ResponseCompletion?.TrySetException(new Exception(err.Data.Message)); + var errMsg = err.Data?.Message ?? "Unknown error"; + Invoke(() => OnError?.Invoke(sessionName, errMsg)); + state.ResponseCompletion?.TrySetException(new Exception(errMsg)); state.Info.IsProcessing = false; Invoke(() => OnStateChanged?.Invoke()); break; @@ -1276,12 +1301,15 @@ await state.Session.SendAsync(new MessageOptions try { await state.Session.DisposeAsync(); - var newSession = await _client!.ResumeSessionAsync(state.Info.SessionId, cancellationToken: cancellationToken); + if (_client == null) + throw new InvalidOperationException("Client is not initialized"); + var newSession = await _client.ResumeSessionAsync(state.Info.SessionId, cancellationToken: cancellationToken); var newState = new SessionState { Session = newSession, Info = state.Info }; + newState.ResponseCompletion = state.ResponseCompletion; newSession.On(evt => HandleSessionEvent(newState, evt)); _sessions[sessionName] = newState; state = newState; @@ -1312,6 +1340,8 @@ await state.Session.SendAsync(new MessageOptions Console.WriteLine($"[DEBUG] SendAsync completed, waiting for response..."); + if (state.ResponseCompletion == null) + return ""; // Response already completed via events return await state.ResponseCompletion.Task; } @@ -1436,10 +1466,17 @@ public void SetActiveSession(string? name) public async Task CloseSessionAsync(string name) { + // In remote mode, send close request to server + if (_bridgeClient != null && _bridgeClient.IsConnected) + { + await _bridgeClient.CloseSessionAsync(name); + } + if (!_sessions.TryRemove(name, out var state)) return false; - await state.Session.DisposeAsync(); + if (state.Session is not null) + await state.Session.DisposeAsync(); if (_activeSessionName == name) { @@ -1643,6 +1680,60 @@ public void SetSessionAlias(string sessionId, string alias) } catch { } } + + private static string? GetGitBranch(string? directory) + { + if (string.IsNullOrEmpty(directory)) return null; + try + { + var headFile = FindGitHead(directory); + if (headFile == null) return null; + var head = File.ReadAllText(headFile).Trim(); + return head.StartsWith("ref: refs/heads/") + ? head["ref: refs/heads/".Length..] + : head.Length >= 8 ? head[..8] : head; // detached HEAD — show short SHA + } + catch { return null; } + } + + private static string? FindGitHead(string dir) + { + var d = new DirectoryInfo(dir); + while (d != null) + { + var head = Path.Combine(d.FullName, ".git", "HEAD"); + if (File.Exists(head)) return head; + d = d.Parent; + } + return null; + } + + private string? GetSessionWorkingDirectory(string sessionId) + { + try + { + var eventsFile = Path.Combine(SessionStatePath, sessionId, "events.jsonl"); + if (!File.Exists(eventsFile)) return null; + // Read only enough lines to find session.start + foreach (var line in File.ReadLines(eventsFile).Take(5)) + { + if (string.IsNullOrWhiteSpace(line)) continue; + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + if (!root.TryGetProperty("type", out var t) || t.GetString() != "session.start") continue; + if (root.TryGetProperty("data", out var data)) + { + if (data.TryGetProperty("context", out var ctx) && + ctx.TryGetProperty("cwd", out var cwd)) + return cwd.GetString(); + if (data.TryGetProperty("workingDirectory", out var wd)) + return wd.GetString(); + } + } + } + catch { } + return null; + } } public class UiState diff --git a/Services/ServerManager.cs b/Services/ServerManager.cs index 126bc8aabc..4c2e0390c1 100644 --- a/Services/ServerManager.cs +++ b/Services/ServerManager.cs @@ -6,7 +6,8 @@ namespace AutoPilot.App.Services; public class ServerManager { - private static readonly string PidFilePath = Path.Combine( + private static string? _pidFilePath; + private static string PidFilePath => _pidFilePath ??= Path.Combine( GetCopilotDir(), "autopilot-server.pid"); private static string GetCopilotDir() diff --git a/Services/WsBridgeClient.cs b/Services/WsBridgeClient.cs index 4ad61bdaef..aa154e3d7d 100644 --- a/Services/WsBridgeClient.cs +++ b/Services/WsBridgeClient.cs @@ -178,6 +178,10 @@ public async Task ResumeSessionAsync(string sessionId, string? displayName = nul await SendAsync(BridgeMessage.Create(BridgeMessageTypes.ResumeSession, new ResumeSessionPayload { SessionId = sessionId, DisplayName = displayName }), ct); + public async Task CloseSessionAsync(string sessionName, CancellationToken ct = default) => + await SendAsync(BridgeMessage.Create(BridgeMessageTypes.CloseSession, + new SessionNamePayload { SessionName = sessionName }), ct); + // --- Receive loop --- private async Task ReceiveLoopAsync(CancellationToken ct) diff --git a/Services/WsBridgeServer.cs b/Services/WsBridgeServer.cs index 0a928e2edb..3fc6288192 100644 --- a/Services/WsBridgeServer.cs +++ b/Services/WsBridgeServer.cs @@ -18,6 +18,7 @@ public class WsBridgeServer : IDisposable private int _bridgePort; private CopilotService? _copilot; private readonly ConcurrentDictionary _clients = new(); + private readonly ConcurrentDictionary _clientSendLocks = new(); public int BridgePort => _bridgePort; public bool IsRunning => _listener?.IsListening == true; @@ -128,6 +129,8 @@ public void Stop() catch { } } _clients.Clear(); + foreach (var kvp in _clientSendLocks) kvp.Value.Dispose(); + _clientSendLocks.Clear(); try { _listener?.Stop(); } catch { } _listener = null; Console.WriteLine("[WsBridge] Stopped"); @@ -183,30 +186,37 @@ private async Task HandleClientAsync(HttpListenerContext httpContext, Cancellati var wsContext = await httpContext.AcceptWebSocketAsync(null); ws = wsContext.WebSocket; _clients[clientId] = ws; + _clientSendLocks[clientId] = new SemaphoreSlim(1, 1); Console.WriteLine($"[WsBridge] Client {clientId} connected ({_clients.Count} total)"); // Send initial state - await SendSessionsList(ws, ct); - await SendPersistedSessions(ws, ct); + await SendToClientAsync(clientId, ws, + BridgeMessage.Create(BridgeMessageTypes.SessionsList, BuildSessionsListPayload()), ct); + await SendPersistedToClient(clientId, ws, ct); // Send active session history if (_copilot != null) { var active = _copilot.GetActiveSession(); if (active != null) - await SendSessionHistory(ws, active.Name, ct); + await SendSessionHistoryToClient(clientId, ws, active.Name, ct); } - // Read client commands - var buffer = new byte[8192]; + // Read client commands (with fragmentation support) + var buffer = new byte[65536]; + var messageBuffer = new StringBuilder(); while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested) { var result = await ws.ReceiveAsync(buffer, ct); if (result.MessageType == WebSocketMessageType.Close) break; - if (result.Count > 0) + + messageBuffer.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); + + if (result.EndOfMessage) { - var json = Encoding.UTF8.GetString(buffer, 0, result.Count); - await HandleClientMessage(ws, json, ct); + var json = messageBuffer.ToString(); + messageBuffer.Clear(); + await HandleClientMessage(clientId, ws, json, ct); } } } @@ -219,6 +229,7 @@ private async Task HandleClientAsync(HttpListenerContext httpContext, Cancellati finally { _clients.TryRemove(clientId, out _); + if (_clientSendLocks.TryRemove(clientId, out var lk)) lk.Dispose(); if (ws?.State == WebSocketState.Open) { try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", CancellationToken.None); } @@ -229,7 +240,7 @@ private async Task HandleClientAsync(HttpListenerContext httpContext, Cancellati } } - private async Task HandleClientMessage(WebSocket ws, string json, CancellationToken ct) + private async Task HandleClientMessage(string clientId, WebSocket ws, string json, CancellationToken ct) { var msg = BridgeMessage.Deserialize(json); if (msg == null || _copilot == null) return; @@ -239,13 +250,14 @@ private async Task HandleClientMessage(WebSocket ws, string json, CancellationTo switch (msg.Type) { case BridgeMessageTypes.GetSessions: - await SendSessionsList(ws, ct); + await SendToClientAsync(clientId, ws, + BridgeMessage.Create(BridgeMessageTypes.SessionsList, BuildSessionsListPayload()), ct); break; case BridgeMessageTypes.GetHistory: var histReq = msg.GetPayload(); if (histReq != null) - await SendSessionHistory(ws, histReq.SessionName, ct); + await SendSessionHistoryToClient(clientId, ws, histReq.SessionName, ct); break; case BridgeMessageTypes.SendMessage: @@ -271,7 +283,7 @@ private async Task HandleClientMessage(WebSocket ws, string json, CancellationTo if (switchReq != null) { _copilot.SetActiveSession(switchReq.SessionName); - await SendSessionHistory(ws, switchReq.SessionName, ct); + await SendSessionHistoryToClient(clientId, ws, switchReq.SessionName, ct); } break; @@ -282,7 +294,7 @@ private async Task HandleClientMessage(WebSocket ws, string json, CancellationTo break; case BridgeMessageTypes.GetPersistedSessions: - await SendPersistedSessions(ws, ct); + await SendPersistedToClient(clientId, ws, ct); break; case BridgeMessageTypes.ResumeSession: @@ -294,6 +306,15 @@ private async Task HandleClientMessage(WebSocket ws, string json, CancellationTo await _copilot.ResumeSessionAsync(resumeReq.SessionId, displayName, ct); } break; + + case BridgeMessageTypes.CloseSession: + var closeReq = msg.GetPayload(); + if (closeReq != null) + { + Console.WriteLine($"[WsBridge] Client closing session '{closeReq.SessionName}'"); + await _copilot.CloseSessionAsync(closeReq.SessionName); + } + break; } } catch (Exception ex) @@ -302,18 +323,27 @@ private async Task HandleClientMessage(WebSocket ws, string json, CancellationTo } } - // --- Send helpers --- + // --- Send helpers (per-client lock to prevent concurrent SendAsync) --- - private async Task SendSessionsList(WebSocket ws, CancellationToken ct) + private async Task SendToClientAsync(string clientId, WebSocket ws, BridgeMessage msg, CancellationToken ct) { - if (_copilot == null) return; + if (ws.State != WebSocketState.Open) return; + if (!_clientSendLocks.TryGetValue(clientId, out var sendLock)) return; - var payload = BuildSessionsListPayload(); - var msg = BridgeMessage.Create(BridgeMessageTypes.SessionsList, payload); - await SendAsync(ws, msg, ct); + var bytes = Encoding.UTF8.GetBytes(msg.Serialize()); + await sendLock.WaitAsync(ct); + try + { + if (ws.State == WebSocketState.Open) + await ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, ct); + } + finally + { + sendLock.Release(); + } } - private async Task SendPersistedSessions(WebSocket ws, CancellationToken ct) + private async Task SendPersistedToClient(string clientId, WebSocket ws, CancellationToken ct) { if (_copilot == null) return; @@ -336,10 +366,10 @@ private async Task SendPersistedSessions(WebSocket ws, CancellationToken ct) var msg = BridgeMessage.Create(BridgeMessageTypes.PersistedSessionsList, new PersistedSessionsPayload { Sessions = persisted }); - await SendAsync(ws, msg, ct); + await SendToClientAsync(clientId, ws, msg, ct); } - private async Task SendSessionHistory(WebSocket ws, string sessionName, CancellationToken ct) + private async Task SendSessionHistoryToClient(string clientId, WebSocket ws, string sessionName, CancellationToken ct) { if (_copilot == null) return; @@ -352,7 +382,7 @@ private async Task SendSessionHistory(WebSocket ws, string sessionName, Cancella Messages = session.History.ToList() }; var msg = BridgeMessage.Create(BridgeMessageTypes.SessionHistory, payload); - await SendAsync(ws, msg, ct); + await SendToClientAsync(clientId, ws, msg, ct); } private SessionsListPayload BuildSessionsListPayload() @@ -396,30 +426,34 @@ private void Broadcast(BridgeMessage msg) if (ws.State != WebSocketState.Open) { _clients.TryRemove(id, out _); + if (_clientSendLocks.TryRemove(id, out var lk)) lk.Dispose(); continue; } + if (!_clientSendLocks.TryGetValue(id, out var sendLock)) continue; + + var clientId = id; _ = Task.Run(async () => { + await sendLock.WaitAsync(); try { - await ws.SendAsync(new ArraySegment(bytes), - WebSocketMessageType.Text, true, CancellationToken.None); + if (ws.State == WebSocketState.Open) + await ws.SendAsync(new ArraySegment(bytes), + WebSocketMessageType.Text, true, CancellationToken.None); } catch { - _clients.TryRemove(id, out _); + _clients.TryRemove(clientId, out _); + if (_clientSendLocks.TryRemove(clientId, out var lk2)) lk2.Dispose(); + } + finally + { + sendLock.Release(); } }); } } - private static async Task SendAsync(WebSocket ws, BridgeMessage msg, CancellationToken ct) - { - if (ws.State != WebSocketState.Open) return; - var bytes = Encoding.UTF8.GetBytes(msg.Serialize()); - await ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, ct); - } - public void Dispose() { Stop(); diff --git a/wwwroot/app.css b/wwwroot/app.css index 111c395318..47f6d9340f 100644 --- a/wwwroot/app.css +++ b/wwwroot/app.css @@ -4,6 +4,19 @@ html, body { color: #a0b4cc; } +/* Expanded chat title — global override to prevent truncation */ +.expanded-card .chat-header-top h2 { + margin: 0 !important; + font-size: 1.2rem !important; + width: 100% !important; + max-width: none !important; + white-space: normal !important; + word-break: break-word !important; + overflow: visible !important; + text-overflow: unset !important; + flex-shrink: 0 !important; +} + a, .btn-link { color: #006bb7; }