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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 118 additions & 48 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -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 `<Compile Include>` 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 `<Compile Include>` 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 <UDID> bin/Debug/net10.0-ios/ios-arm64/AutoPilot.App.app/
xcrun devicectl device process launch --device <UDID> 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=<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<string, SessionState>`. 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 `<TrimmerRootAssembly Include="GitHub.Copilot.SDK" />` — 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<string>("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
`<TrimmerRootAssembly Include="GitHub.Copilot.SDK" />` 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<string>("eval", "document.getElementById('id')?.value")` on submit.

### Session Persistence
- Active sessions: `~/.copilot/autopilot-active-sessions.json`
- Session state: `~/.copilot/session-state/<guid>/events.jsonl` (SDK-managed)
- UI state: `~/.copilot/autopilot-ui-state.json`
- Settings: `~/.copilot/autopilot-settings.json`
- Crash log: `~/.copilot/autopilot-crash.log`
2 changes: 1 addition & 1 deletion Components/ChatMessageList.razor
Original file line number Diff line number Diff line change
@@ -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) *@

<div class="chat-message-list @(Compact ? "compact" : "full")">
@if (!Messages.Any() && string.IsNullOrEmpty(StreamingContent))
Expand Down
15 changes: 3 additions & 12 deletions Components/Layout/SessionSidebar.razor
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@
</button>
<span class="mobile-title"><img src="autopilot_logo.png" width="18" height="18" style="vertical-align:middle;border-radius:4px" /> AutoPilot</span>
<div class="mobile-tabs">
<a href="/" class="mobile-tab @(currentPage == "/" ? "active" : "")" @onclick='() => { CopilotService.SaveUiState("/"); currentPage = "/"; }'>Chat</a>
<a href="/dashboard" class="mobile-tab @(currentPage == "/dashboard" ? "active" : "")" @onclick='() => { CopilotService.SaveUiState("/dashboard"); currentPage = "/dashboard"; }'>Dashboard</a>
<a href="/" class="mobile-tab @(currentPage == "/" || currentPage == "/dashboard" ? "active" : "")" @onclick='() => { CopilotService.SaveUiState("/"); currentPage = "/"; }'>Dashboard</a>
<a href="/settings" class="mobile-tab @(currentPage == "/settings" ? "active" : "")" @onclick='() => { CopilotService.SaveUiState("/settings"); currentPage = "/settings"; }'>⚙️</a>
</div>
</div>
Expand Down Expand Up @@ -76,8 +75,7 @@ else
}
</p>
<div class="nav-tabs">
<a href="/" class="nav-tab @(currentPage == "/" ? "active" : "")" @onclick='() => { CopilotService.SaveUiState("/"); currentPage = "/"; }'>Chat</a>
<a href="/dashboard" class="nav-tab @(currentPage == "/dashboard" ? "active" : "")" @onclick='() => { CopilotService.SaveUiState("/dashboard"); currentPage = "/dashboard"; }'>Dashboard</a>
<a href="/" class="nav-tab @(currentPage == "/" || currentPage == "/dashboard" ? "active" : "")" @onclick='() => { CopilotService.SaveUiState("/"); currentPage = "/"; }'>Dashboard</a>
<a href="/settings" class="nav-tab @(currentPage == "/settings" ? "active" : "")" @onclick='() => { CopilotService.SaveUiState("/settings"); currentPage = "/settings"; }'>Settings</a>
</div>
</div>
Expand Down Expand Up @@ -339,10 +337,7 @@ else
private void RefreshSessions()
{
sessions = CopilotService.GetAllSessions().ToList();
if (showPersistedSessions)
{
LoadPersistedSessions();
}
LoadPersistedSessions();
InvokeAsync(StateHasChanged);
}

Expand All @@ -357,10 +352,6 @@ else
private void TogglePersistedSessions()
{
showPersistedSessions = !showPersistedSessions;
if (showPersistedSessions)
{
LoadPersistedSessions();
}
}

private void OnSessionNameInput(ChangeEventArgs e)
Expand Down
Loading