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
-
-
-
- @if (!sessions.Any())
+
+ @if (!CopilotService.IsInitialized)
{
+
+
+
Connecting to Copilot...
+ @if (!string.IsNullOrEmpty(initError))
+ {
+
@initError
+
Retry
+ }
+
+ }
+ else if (!sessions.Any() && expandedSession == null)
+ {
+
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";
+
+
+
+
+ @{ var expandedMessages = GetWindowedMessages(session.Name, session.History); }
+ @if (session.History.Count > expandedMessages.Count)
+ {
+ LoadMoreExpandedMessages(session.Name)">
+ ⬆️ Load more (@(session.History.Count - expandedMessages.Count) remaining)
+
+ }
+
+
+
+ @if (errorBySession.TryGetValue(session.Name, out var err2))
+ {
+
+ ⚠️ @err2
+ DismissError(session.Name)">×
+
+ }
+
+
+
+ }
+ }
else
{
+
@foreach (var session in sessions)
{
var isCompleted = completedSessions.Contains(session.Name);
var cardClass = session.IsProcessing ? "processing" : isCompleted ? "completed" : "idle";
-
+
-
+
+ @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
+ DismissError(session.Name)">✕
+
+ }
+
+ @if (session.MessageQueue.Any())
+ {
+
+ 📋 Queued (@session.MessageQueue.Count)
+ ClearQueue(session.Name)">Clear
+
+ }
+
+ @if (intentBySession.TryGetValue(session.Name, out var intentG) && !string.IsNullOrEmpty(intentG))
+ {
+
💭 @intentG
+ }
+
- SendFromCard(session.Name)"
- disabled="@session.IsProcessing">
- @(session.IsProcessing ? "…" : "➤")
+ placeholder="@(session.IsProcessing ? "Queue a message…" : $"Send to {session.Name}...")" />
+ @if (session.IsProcessing)
+ {
+ StopSession(session.Name)" title="Stop">■
+ }
+ SendFromCard(session.Name)">
+ @(session.IsProcessing ? "📋" : "➤")
}
-
-
-
Broadcast to All
-
-
- Send 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
-
Retry
- }
-
- }
- else if (activeSession == null)
- {
-
-
-
Welcome to AutoPilot
-
Create a new session from the sidebar to start chatting with Copilot.
-
- }
- else
- {
-
-
-
- @if (HasMoreMessages)
- {
-
- ⬆️
- Load @Math.Min(50, activeSession.History.Count - visibleMessageCount) older messages (@(activeSession.History.Count - visibleMessageCount) remaining)
-
- }
-
-
-
- @if (!string.IsNullOrEmpty(lastError))
- {
-
- ⚠️ @lastError
- lastError = null">×
-
- }
-
-
- }
-
- @if (showDebugPanel)
- {
-
- }
-
-
-@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
+
+ Shortcut Action
+
+ ⌘E / Ctrl+E Expand focused session / collapse back to grid
+ Escape Collapse expanded session back to grid
+ Tab Focus next session input (grid) or switch to next session (expanded)
+ Shift+Tab Focus previous session input (grid) or switch to previous session (expanded)
+ Enter Send 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;
}