diff --git a/AutoPilot.App.csproj b/AutoPilot.App.csproj index 7a041fde7c..a5bd647b8b 100644 --- a/AutoPilot.App.csproj +++ b/AutoPilot.App.csproj @@ -53,7 +53,7 @@ - + @@ -69,8 +69,8 @@ - - + + diff --git a/Components/ChatMessageList.razor b/Components/ChatMessageList.razor index 47212cdb9a..91b6d3b569 100644 --- a/Components/ChatMessageList.razor +++ b/Components/ChatMessageList.razor @@ -299,9 +299,11 @@ System.IO.File.Exists(trimmed)) { var dataUri = FileToDataUri(trimmed); + var fileName = System.IO.Path.GetFileName(trimmed); + var safeName = System.Net.WebUtility.HtmlEncode(fileName); if (dataUri != null) { - sb.Append($"
\"{System.Net.WebUtility.HtmlEncode(System.IO.Path.GetFileName(trimmed))}\"{System.Net.WebUtility.HtmlEncode(System.IO.Path.GetFileName(trimmed))}
"); + sb.Append($"
\"{safeName}\"📷 {safeName}
"); continue; } } diff --git a/Components/ChatMessageList.razor.css b/Components/ChatMessageList.razor.css index b60f2bd8c7..a5951e0f07 100644 --- a/Components/ChatMessageList.razor.css +++ b/Components/ChatMessageList.razor.css @@ -8,21 +8,21 @@ color: rgba(200,216,240,0.3); text-align: center; margin: auto; - font-size: 0.95rem; + font-size: var(--type-body); } /* === Compact mode (dashboard cards) === */ .chat-message-list.compact .chat-msg { display: flex; gap: 0.5rem; - font-size: 0.95rem; + font-size: var(--type-body); line-height: 1.4; } .chat-message-list.compact .chat-msg-role { font-weight: 600; flex-shrink: 0; - font-size: 0.8rem; + font-size: var(--type-callout); padding: 0.1rem 0.3rem; border-radius: 3px; align-self: flex-start; @@ -142,7 +142,7 @@ } .chat-message-list.full .chat-msg-time { - font-size: 0.75rem; + font-size: var(--type-subhead); color: rgba(200,216,240,0.3); margin-top: 0.25rem; } @@ -152,7 +152,7 @@ } .chat-message-list.full .system-text { - font-size: 0.85rem; + font-size: var(--type-body); color: #646e8a; font-style: italic; text-align: center; @@ -171,7 +171,7 @@ background: none; border: none; color: rgba(168, 85, 247, 0.7); - font-size: 0.85rem; + font-size: var(--type-body); cursor: pointer; display: flex; align-items: center; @@ -181,7 +181,7 @@ } ::deep .reasoning-content { - font-size: 0.85rem; + font-size: var(--type-body); color: #7a85a0; white-space: pre-wrap; line-height: 1.5; @@ -198,7 +198,7 @@ margin: 0.25rem 1rem; border-radius: 8px; padding: 0.5rem 0.75rem; - font-size: 0.85rem; + font-size: var(--type-body); border: 1px solid rgba(78,168,209,0.15); background: rgba(78,168,209,0.03); } @@ -223,7 +223,7 @@ } ::deep .tool-result-content { - font-size: 0.8rem; + font-size: var(--type-callout); color: #7a85a0; white-space: pre-wrap; word-break: break-word; @@ -236,7 +236,7 @@ background: none; border: none; color: rgba(78, 168, 209, 0.7); - font-size: 0.8rem; + font-size: var(--type-callout); cursor: pointer; padding: 0; } @@ -255,7 +255,7 @@ gap: 0.5rem; margin: 0.1rem 1rem; padding: 0.25rem 0.4rem; - font-size: 0.82rem; + font-size: var(--type-callout); cursor: pointer; border-radius: 4px; transition: background 0.15s; @@ -312,13 +312,13 @@ overflow: hidden; text-overflow: ellipsis; font-family: 'SF Mono', 'Menlo', 'Consolas', monospace; - font-size: 0.75rem; + font-size: var(--type-subhead); } ::deep .action-detail-hint { margin-left: auto; color: rgba(200,216,240,0.3); - font-size: 0.7rem; + font-size: var(--type-footnote); white-space: nowrap; flex-shrink: 0; } @@ -333,7 +333,7 @@ } ::deep .action-expanded-result pre { - font-size: 0.75rem; + font-size: var(--type-subhead); color: rgba(200,216,240,0.5); white-space: pre-wrap; word-break: break-word; @@ -359,7 +359,7 @@ border: 1px solid rgba(0, 195, 91, 0.3); border-radius: 8px; color: #00c35b; - font-size: 0.95rem; + font-size: var(--type-body); } ::deep .error-card { @@ -373,7 +373,7 @@ border: 1px solid rgba(248, 113, 113, 0.3); border-radius: 8px; color: #fca5a5; - font-size: 0.9rem; + font-size: var(--type-body); } ::deep .markdown-body { @@ -390,7 +390,7 @@ background: rgba(78,168,209,0.15); padding: 0.15em 0.3em; border-radius: 3px; - font-size: 0.9em; + font-size: 0.85em; } ::deep .markdown-body pre { @@ -434,31 +434,69 @@ /* === User image attachments inline === */ ::deep .user-image-attachment { - display: inline-block; - margin: 0.3rem 0.2rem 0.3rem 0; - border-radius: 8px; + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin: 0.2rem 0.3rem 0.2rem 0; + border-radius: 6px; overflow: hidden; - border: 1px solid rgba(255,255,255,0.15); - background: rgba(0,0,0,0.2); - max-width: 300px; - vertical-align: top; + border: 1px solid rgba(255,255,255,0.12); + background: rgba(0,0,0,0.25); + padding: 0.2rem 0.5rem 0.2rem 0.25rem; + vertical-align: middle; + cursor: pointer; } ::deep .user-image-attachment img { - max-width: 300px; - max-height: 200px; - object-fit: contain; - display: block; - border-radius: 8px 8px 0 0; + width: 40px; + height: 40px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; } ::deep .user-image-name { - display: block; - font-size: 0.65rem; - color: rgba(255,255,255,0.4); - padding: 2px 6px 4px; - text-align: center; + font-size: var(--type-subhead); + color: rgba(200,216,240,0.6); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + max-width: 160px; +} + +/* === Mobile: tighter chat bubbles matching iOS chat conventions === */ +@media (max-width: 640px) { + .chat-message-list.full .chat-msg { + padding: 0.4rem 0.5rem; + gap: 0.35rem; + } + + .chat-message-list.full .chat-msg-text { + font-size: var(--type-callout); + line-height: 1.45; + } + + .chat-message-list.full .chat-msg.user .chat-msg-content, + .chat-message-list.full .chat-msg.assistant .chat-msg-content { + padding: 0.5rem 0.75rem; + max-width: 85%; + } + + .chat-message-list.full .chat-msg-time { + font-size: var(--type-caption1); + } + + .chat-message-list.full .chat-msg.user .chat-msg-avatar { + width: 30px; + height: 30px; + } + + .chat-message-list.full .chat-msg.assistant .chat-msg-avatar { + width: 40px; + height: 40px; + } + + .chat-message-list.full .system-text { + font-size: var(--type-callout); + } } diff --git a/Components/Layout/MainLayout.razor.css b/Components/Layout/MainLayout.razor.css index 2c2154fd9b..e582d760eb 100644 --- a/Components/Layout/MainLayout.razor.css +++ b/Components/Layout/MainLayout.razor.css @@ -61,6 +61,7 @@ main { transform: translateX(-100%); transition: transform 0.25s ease; overflow-y: auto; + padding-top: env(safe-area-inset-top, 0px); } .flyout-panel.open { diff --git a/Components/Layout/NavMenu.razor.css b/Components/Layout/NavMenu.razor.css index d30a65ec02..10e6f5e1cf 100644 --- a/Components/Layout/NavMenu.razor.css +++ b/Components/Layout/NavMenu.razor.css @@ -21,7 +21,7 @@ } .navbar-brand { - font-size: 1.1rem; + font-size: var(--type-title2); } .bi { @@ -47,7 +47,7 @@ } .nav-item { - font-size: 0.9rem; + font-size: var(--type-body); padding-bottom: 0.5rem; } diff --git a/Components/Layout/SessionSidebar.razor b/Components/Layout/SessionSidebar.razor index e413aa7fd7..14cbef93d6 100644 --- a/Components/Layout/SessionSidebar.razor +++ b/Components/Layout/SessionSidebar.razor @@ -10,10 +10,10 @@ - AutoPilot + } @@ -75,8 +75,8 @@ else }

diff --git a/Components/Layout/SessionSidebar.razor.css b/Components/Layout/SessionSidebar.razor.css index 7e65dcc103..1483809633 100644 --- a/Components/Layout/SessionSidebar.razor.css +++ b/Components/Layout/SessionSidebar.razor.css @@ -26,11 +26,11 @@ .sidebar-header h3 { margin: 0 0 0.25rem 0; - font-size: 1.4rem; + font-size: var(--type-title1); } .sidebar-header .status { - font-size: 1rem; + font-size: var(--type-title3); margin: 0; } @@ -57,18 +57,23 @@ border-radius: 6px; background: rgba(255,255,255,0.1); color: #a0b4cc; - font-size: 1rem; + font-size: var(--type-title3); } .model-select { - padding: 0.5rem; + padding: 0.5rem 1.8rem 0.5rem 0.5rem; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.1); color: #a0b4cc; - font-size: 0.9rem; + font-size: var(--type-body); min-width: 0; max-width: 100%; + -webkit-appearance: none; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a0b4cc' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.5rem center; } .model-select option { @@ -92,7 +97,7 @@ background: #3b82f6; color: #a0b4cc; cursor: pointer; - font-size: 1rem; + font-size: var(--type-title3); font-weight: bold; } @@ -119,7 +124,7 @@ padding: 0.3rem 0.5rem !important; background: rgba(255,255,255,0.1) !important; border: 1px solid rgba(255,255,255,0.2) !important; - font-size: 0.75rem !important; + font-size: var(--type-subhead) !important; font-weight: normal !important; } @@ -134,7 +139,7 @@ } .dir-input { - font-size: 0.85rem !important; + font-size: var(--type-body) !important; } .dir-browse-btn { @@ -142,7 +147,7 @@ background: rgba(255,255,255,0.1) !important; border: 1px solid rgba(255,255,255,0.2) !important; border-radius: 6px; - font-size: 0.9rem !important; + font-size: var(--type-body) !important; font-weight: normal !important; color: #a0b4cc; cursor: pointer; @@ -160,7 +165,7 @@ .section-header { padding: 0.5rem 0.75rem; - font-size: 0.8rem; + font-size: var(--type-callout); font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; @@ -177,14 +182,14 @@ } .toggle-icon { - font-size: 0.6rem; + font-size: var(--type-caption1); } .no-sessions { text-align: center; color: rgba(255,255,255,0.5); padding: 1rem; - font-size: 1rem; + font-size: var(--type-title3); line-height: 1.5; } @@ -221,7 +226,7 @@ } .open-badge { - font-size: 0.75rem; + font-size: var(--type-subhead); color: #00c35b; white-space: nowrap; flex-shrink: 0; @@ -257,7 +262,7 @@ .session-name { font-weight: 500; - font-size: 0.95rem; + font-size: var(--type-body); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -278,7 +283,7 @@ .persisted-name { font-family: -apple-system, BlinkMacSystemFont, sans-serif; - font-size: 0.9rem; + font-size: var(--type-body); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -286,7 +291,7 @@ } .resumed-badge { - font-size: 0.6rem; + font-size: var(--type-caption1); padding: 0.1rem 0.3rem; background: rgba(251, 191, 36, 0.3); border-radius: 3px; @@ -299,7 +304,7 @@ justify-content: center; width: 1.2em; height: 1.2em; - font-size: 0.7rem; + font-size: var(--type-footnote); font-weight: 600; background: rgba(255,255,255,0.12); border: 1px solid rgba(255,255,255,0.2); @@ -309,17 +314,17 @@ } .session-meta { - font-size: 0.95rem; + font-size: var(--type-body); color: rgba(255,255,255,0.5); } .session-meta-time { - font-size: 0.8rem; + font-size: var(--type-callout); color: rgba(255,255,255,0.4); } .session-meta-dir { - font-size: 0.8rem; + font-size: var(--type-callout); color: rgba(255,255,255,0.35); white-space: nowrap; overflow: hidden; @@ -335,7 +340,7 @@ .processing { color: #fbbf24; animation: pulse 1s infinite; - font-size: 0.75rem; + font-size: var(--type-subhead); white-space: nowrap; } @@ -352,7 +357,7 @@ background: transparent; color: rgba(255,255,255,0.5); cursor: pointer; - font-size: 1rem; + font-size: var(--type-title3); line-height: 1; opacity: 0; transition: all 0.15s ease; @@ -375,7 +380,7 @@ } .rename-btn { - font-size: 0.65rem; + font-size: var(--type-footnote); width: auto; padding: 0 0.3rem; letter-spacing: 0.02em; @@ -397,7 +402,7 @@ border-radius: 6px; background: rgba(255,255,255,0.1); color: #a0b4cc; - font-size: 1rem; + font-size: var(--type-title3); } .filter-input::placeholder { @@ -411,7 +416,7 @@ } .resume-icon { - font-size: 0.85rem; + font-size: var(--type-body); color: rgba(255,255,255,0.3); white-space: nowrap; transition: color 0.15s ease; @@ -441,7 +446,7 @@ background: #4ea8d1; color: #a0b4cc; cursor: pointer; - font-size: 0.8rem; + font-size: var(--type-callout); font-weight: 600; white-space: nowrap; transition: background 0.15s ease; @@ -463,7 +468,7 @@ background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6); cursor: pointer; - font-size: 0.8rem; + font-size: var(--type-callout); transition: all 0.15s ease; } @@ -481,7 +486,7 @@ } .resume-error { - font-size: 0.75rem; + font-size: var(--type-subhead); color: #f87171; line-height: 1.3; max-width: 180px; @@ -544,7 +549,7 @@ background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.7); text-decoration: none; - font-size: 0.8rem; + font-size: var(--type-callout); line-height: 1.2; transition: all 0.15s ease; font-family: -apple-system, BlinkMacSystemFont, sans-serif; @@ -568,7 +573,7 @@ border-radius: 4px; background: rgba(255,255,255,0.15); color: #a0b4cc; - font-size: 1rem; + font-size: var(--type-title3); font-weight: 500; font-family: inherit; outline: none; @@ -578,45 +583,59 @@ .mobile-bar { display: flex; align-items: center; - gap: 0.75rem; - padding: 0.5rem 1rem; + gap: 0.5rem; + padding: calc(env(safe-area-inset-top, 0px) + 0.6rem) 0.75rem 0.6rem; color: #a0b4cc; } .hamburger-btn { background: none; border: none; - color: #a0b4cc; + color: rgba(200,216,240,0.6); cursor: pointer; - padding: 0.25rem; + padding: 0.4rem; display: flex; align-items: center; + font-size: var(--type-title3); + -webkit-tap-highlight-color: transparent; +} + +.hamburger-btn:active { + color: #60a5fa; } .mobile-title { - font-size: 1.1rem; - font-weight: 600; + font-size: var(--type-title3); + font-weight: 700; + letter-spacing: -0.02em; + flex-shrink: 0; } .mobile-tabs { display: flex; - gap: 0.5rem; + gap: 0.25rem; margin-left: auto; + background: rgba(255,255,255,0.06); + border-radius: 8px; + padding: 0.15rem; } .mobile-tab { - padding: 0.35rem 0.75rem; + padding: 0.3rem 0.7rem; border-radius: 6px; - background: rgba(255,255,255,0.08); - color: rgba(255,255,255,0.7); + background: transparent; + color: rgba(200,216,240,0.5); text-decoration: none; - font-size: 0.85rem; - transition: all 0.15s ease; + font-size: var(--type-subhead); + font-weight: 500; + transition: all 0.2s ease; + -webkit-tap-highlight-color: transparent; } .mobile-tab.active { - background: rgba(59, 130, 246, 0.25); + background: rgba(59, 130, 246, 0.2); color: #60a5fa; + font-weight: 600; } /* Flyout panel content */ @@ -634,7 +653,7 @@ .flyout-header h3 { margin: 0; - font-size: 1.2rem; + font-size: var(--type-title2); color: #a0b4cc; } @@ -643,7 +662,7 @@ border: none; color: rgba(255,255,255,0.5); cursor: pointer; - font-size: 1.2rem; + font-size: var(--type-title2); padding: 0.25rem; } @@ -653,6 +672,62 @@ /* === Mobile responsive === */ @media (max-width: 640px) { + .flyout-header { + padding: 0.6rem 0.75rem; + } + + .flyout-header h3 { + font-size: var(--type-body); + } + + .flyout-close-btn { + font-size: var(--type-body); + } + + .new-session { + padding: 0.6rem 0.75rem; + gap: 0.4rem; + } + + .new-session input { + padding: 0.4rem 0.5rem; + font-size: var(--type-callout); + border-radius: 6px; + } + + .model-select { + padding: 0.35rem 0.4rem; + font-size: var(--type-callout); + } + + .new-session button { + padding: 0.4rem 0.6rem; + font-size: var(--type-callout); + } + + .dir-toggle-btn { + font-size: var(--type-footnote) !important; + padding: 0.2rem 0.4rem !important; + } + + .section-header { + padding: 0.35rem 0.5rem; + font-size: var(--type-footnote); + } + + .session-item { + padding: 0.5rem; + } + + .session-name { + font-size: var(--type-callout); + } + + .no-sessions { + font-size: var(--type-callout); + padding: 0.75rem; + } + .session-item.persisted { flex-wrap: wrap; } @@ -660,6 +735,6 @@ width: 100%; text-align: right; margin-top: 0.25rem; - font-size: 0.8rem; + font-size: var(--type-callout); } } diff --git a/Components/Pages/Dashboard.razor b/Components/Pages/Dashboard.razor index f0b45e3967..e08cf22a52 100644 --- a/Components/Pages/Dashboard.razor +++ b/Components/Pages/Dashboard.razor @@ -271,6 +271,7 @@ private int fontSize = 20; private string? expandedSession; private string? _focusedInputId; + private string? _lastActiveSession; private int _cursorStart; private int _cursorEnd; private DotNetObjectReference? _dotNetRef; @@ -368,6 +369,10 @@ if (idx < 0) idx = 0; idx = e.shiftKey ? (idx - 1 + inputs.length) % inputs.length : (idx + 1) % inputs.length; inputs[idx].focus(); + var card = inputs[idx].closest('.session-card'); + if (card && card.dataset.session && window.__dashRef) { + window.__dashRef.invokeMethodAsync('JsSelectSession', card.dataset.session); + } } if ((e.metaKey || e.ctrlKey) && e.key === 'e') { e.preventDefault(); @@ -444,10 +449,69 @@ "); } + private async Task SafeRefreshAsync() + { + // Capture all card input values before re-render wipes them + try + { + var json = await JS.InvokeAsync("eval", @" + (function() { + var result = {}; + var active = document.activeElement; + document.querySelectorAll('.card-input input, .card-input textarea, .expanded-card .input-area textarea').forEach(function(el) { + if (el.id && el.value) result[el.id] = el.value; + }); + if (active && active.id) result['__focused'] = active.id; + if (active) { result['__selStart'] = active.selectionStart || 0; result['__selEnd'] = active.selectionEnd || 0; } + return JSON.stringify(result); + })(); + "); + if (!string.IsNullOrEmpty(json)) + { + var saved = System.Text.Json.JsonSerializer.Deserialize>(json); + if (saved != null) + { + foreach (var (id, val) in saved) + { + if (id.StartsWith("__")) continue; + // Map input id back to session name + var sessionName = id.Replace("input-", "").Replace("-", " "); + if (!string.IsNullOrEmpty(val)) + draftBySession[sessionName] = val; + } + if (saved.TryGetValue("__focused", out var fid)) + _focusedInputId = fid; + if (saved.TryGetValue("__selStart", out var ss) && int.TryParse(ss, out var start)) + _cursorStart = start; + if (saved.TryGetValue("__selEnd", out var se) && int.TryParse(se, out var end)) + _cursorEnd = end; + } + } + } + catch { } + StateHasChanged(); + } + private void RefreshState() { sessions = CopilotService.GetAllSessions().ToList(); - InvokeAsync(StateHasChanged); + // Detect sidebar-initiated session switch + var active = CopilotService.ActiveSessionName; + if (active != null && active != _lastActiveSession && sessions.Any(s => s.Name == active)) + { + _lastActiveSession = active; + if (expandedSession != null) + { + // In expanded mode: switch to the selected session + expandedSession = active; + } + else + { + // In grid mode: focus that session's input + _focusedInputId = $"input-{active.Replace(" ", "-")}"; + } + } + InvokeAsync(SafeRefreshAsync); } private void HandleComplete(string sessionName, string summary) @@ -455,11 +519,11 @@ completedSessions.Add(sessionName); streamingBySession.Remove(sessionName); activityBySession.Remove(sessionName); - InvokeAsync(StateHasChanged); + InvokeAsync(SafeRefreshAsync); _ = Task.Delay(10000).ContinueWith(_ => { completedSessions.Remove(sessionName); - InvokeAsync(StateHasChanged); + InvokeAsync(SafeRefreshAsync); }); } @@ -468,13 +532,13 @@ if (!streamingBySession.ContainsKey(sessionName)) streamingBySession[sessionName] = ""; streamingBySession[sessionName] += content; - InvokeAsync(StateHasChanged); + InvokeAsync(SafeRefreshAsync); } private void HandleActivity(string sessionName, string activity) { activityBySession[sessionName] = activity; - InvokeAsync(StateHasChanged); + InvokeAsync(SafeRefreshAsync); } private void HandleToolStarted(string sessionName, string toolName, string callId, string? inputSummary) @@ -489,7 +553,7 @@ Input = inputSummary, StartedAt = DateTime.Now }); - InvokeAsync(StateHasChanged); + InvokeAsync(SafeRefreshAsync); } private void HandleToolCompleted(string sessionName, string callId, string result, bool success) @@ -506,13 +570,13 @@ } } currentToolBySession.Remove(sessionName); - InvokeAsync(StateHasChanged); + InvokeAsync(SafeRefreshAsync); } private void HandleIntentChanged(string sessionName, string intent) { intentBySession[sessionName] = intent; - InvokeAsync(StateHasChanged); + InvokeAsync(SafeRefreshAsync); } private void HandleUsageInfoChanged(string sessionName, SessionUsageInfo info) @@ -530,28 +594,28 @@ { usageBySession[sessionName] = info; } - InvokeAsync(StateHasChanged); + InvokeAsync(SafeRefreshAsync); } private void HandleError(string sessionName, string error) { if (error.Contains("cancell", StringComparison.OrdinalIgnoreCase)) return; errorBySession[sessionName] = error; - InvokeAsync(StateHasChanged); + InvokeAsync(SafeRefreshAsync); } private void HandleTurnStart(string sessionName) { currentToolBySession.Remove(sessionName); toolActivitiesBySession[sessionName] = new(); - InvokeAsync(StateHasChanged); + InvokeAsync(SafeRefreshAsync); } private void HandleTurnEnd(string sessionName) { intentBySession.Remove(sessionName); currentToolBySession.Remove(sessionName); - InvokeAsync(StateHasChanged); + InvokeAsync(SafeRefreshAsync); } private void DismissError(string sessionName) @@ -605,6 +669,7 @@ { await SaveDraftsAndCursor(); expandedSession = sessionName; + CopilotService.SwitchSession(sessionName); } private async Task CollapseExpanded() @@ -701,6 +766,7 @@ { await SaveDraftsAndCursor(); expandedSession = sessionName; + CopilotService.SwitchSession(sessionName); StateHasChanged(); } @@ -713,12 +779,19 @@ if (idx < 0) idx = 0; idx = reverse ? (idx - 1 + sessions.Count) % sessions.Count : (idx + 1) % sessions.Count; expandedSession = sessions[idx].Name; + CopilotService.SwitchSession(expandedSession); _focusedInputId = $"input-{expandedSession.Replace(" ", "-")}"; _cursorStart = 0; _cursorEnd = 0; StateHasChanged(); } + [JSInvokable] + public void JsSelectSession(string sessionName) + { + CopilotService.SwitchSession(sessionName); + } + private const int DefaultMessageWindow = 25; private List GetWindowedMessages(string sessionName, IReadOnlyList history) diff --git a/Components/Pages/Dashboard.razor.css b/Components/Pages/Dashboard.razor.css index 5efbf14420..b368a1b0c3 100644 --- a/Components/Pages/Dashboard.razor.css +++ b/Components/Pages/Dashboard.razor.css @@ -22,11 +22,11 @@ .dashboard-header h2 { margin: 0; - font-size: 1.6rem; + font-size: var(--type-large-title); } .session-count { - font-size: 1rem; + font-size: var(--type-title3); color: rgba(255,255,255,0.5); } @@ -36,7 +36,9 @@ justify-content: center; flex: 1; color: rgba(255,255,255,0.5); - font-size: 1.1rem; + font-size: var(--type-title2); + text-align: center; + padding: 0 1.5rem 2rem; } .initializing { @@ -279,7 +281,7 @@ background: none; border: none; color: rgba(255,255,255,0.4); - font-size: 0.85rem; + font-size: var(--type-body); padding: 0.2rem 0.4rem; border-radius: 4px; cursor: pointer; @@ -336,7 +338,7 @@ .card-title h3 { margin: 0; - font-size: 1.15rem; + font-size: var(--type-title2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -439,7 +441,7 @@ color: rgba(255,255,255,0.3); text-align: center; margin: auto; - font-size: 0.95rem; + font-size: var(--type-body); } .load-more-btn { @@ -462,14 +464,14 @@ .card-msg { display: flex; gap: 0.5rem; - font-size: 0.95rem; + font-size: var(--type-body); line-height: 1.4; } .card-msg-role { font-weight: 600; flex-shrink: 0; - font-size: 0.8rem; + font-size: var(--type-callout); padding: 0.1rem 0.3rem; border-radius: 3px; align-self: flex-start; @@ -507,7 +509,7 @@ border-radius: 6px; background: rgba(255,255,255,0.08); color: #a0b4cc; - font-size: 1rem; + font-size: var(--type-title3, 1rem); font-family: inherit; resize: none; } @@ -532,7 +534,7 @@ background: #3b82f6; color: #a0b4cc; cursor: pointer; - font-size: 1.1rem; + font-size: var(--type-title2); } .card-input button:hover:not(:disabled) { @@ -610,14 +612,14 @@ @media (max-width: 640px) { .dashboard { padding: 0.75rem; } .dashboard-header { gap: 0.5rem; margin-bottom: 0.75rem; } - .dashboard-header h2 { font-size: 1.1rem; } - .session-count { font-size: 0.8rem; } - .no-sessions-dash { font-size: 0.9rem; } + .dashboard-header h2 { font-size: var(--type-title2); } + .session-count { font-size: var(--type-callout); } + .no-sessions-dash { font-size: var(--type-body); } .session-grid { grid-template-columns: 1fr; gap: 0.75rem; } .session-card { padding: 0.75rem; border-radius: 8px; } - .card-header h3 { font-size: 0.95rem; } - .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; } + .card-header h3 { font-size: var(--type-body, 0.95rem); } + .card-model-badge { font-size: var(--type-footnote, 0.65rem); padding: 0.1rem 0.3rem; } + .card-close { font-size: var(--type-subhead, 0.75rem); } + .card-messages { max-height: 180px; gap: 0.3rem; font-size: var(--type-callout, 0.8rem); } .card-dir { max-width: 160px; } } diff --git a/Components/Pages/Settings.razor b/Components/Pages/Settings.razor index e69a5c9f13..5cf15b3c62 100644 --- a/Components/Pages/Settings.razor +++ b/Components/Pages/Settings.razor @@ -10,23 +10,32 @@
-

Connection Settings

+
+

Connection Settings

+
+ + @CopilotService.CurrentMode — @(CopilotService.IsInitialized ? "Connected" : "Disconnected") +
+
-
-

Transport Mode

-
- @foreach (var mode in PlatformHelper.AvailableModes) - { -
-
@GetModeIcon(mode)
-
@mode
-
@GetModeDescription(mode)
-
- } + @if (PlatformHelper.AvailableModes.Length > 1) + { +
+

Transport Mode

+
+ @foreach (var mode in PlatformHelper.AvailableModes) + { +
+
@GetModeIcon(mode)
+
@mode
+
@GetModeDescription(mode)
+
+ } +
-
+ } @if (settings.Mode == ConnectionMode.Persistent) { @@ -60,11 +69,12 @@
+ } - @if (serverAlive && PlatformHelper.IsDesktop) - { -
-

Share via DevTunnel

+ @if (PlatformHelper.IsDesktop && (settings.Mode == ConnectionMode.Embedded || (settings.Mode == ConnectionMode.Persistent && serverAlive))) + { +
+

Share via DevTunnel

@if (!devTunnelAvailable) {
@@ -139,12 +149,11 @@ }
} - } @if (settings.Mode == ConnectionMode.Remote) {
-

Remote Server

+

Remote Server

Connect to a Copilot server running on another machine (via DevTunnel URL).

@@ -212,6 +221,12 @@
} + + @if (!string.IsNullOrEmpty(statusMessage)) + { +
@(statusClass == "success" ? "✓ " : statusClass == "error" ? "✗ " : "")@statusMessage
+ } + }
@code { @@ -225,6 +240,25 @@ private bool tunnelBusy; private bool showToken; private string? qrCodeDataUri; + private CancellationTokenSource? _statusCts; + + private void ShowStatus(string message, string cls, int dismissMs = 3000) + { + _statusCts?.Cancel(); + statusMessage = message; + statusClass = cls; + StateHasChanged(); + if (dismissMs > 0) + { + _statusCts = new CancellationTokenSource(); + var token = _statusCts.Token; + _ = Task.Delay(dismissMs, token).ContinueWith(_ => + { + if (!token.IsCancellationRequested) + InvokeAsync(() => { statusMessage = null; StateHasChanged(); }); + }, TaskScheduler.Default); + } + } protected override async Task OnInitializedAsync() { @@ -335,9 +369,7 @@ if (!string.IsNullOrEmpty(url)) settings.RemoteUrl = url; - statusMessage = "QR code scanned!"; - statusClass = "success"; - StateHasChanged(); + ShowStatus("QR code scanned!", "success"); } private async Task TunnelLogin() @@ -372,9 +404,7 @@ if (DevTunnelService.TunnelUrl != null) { await Microsoft.Maui.ApplicationModel.DataTransfer.Clipboard.SetTextAsync(DevTunnelService.TunnelUrl); - statusMessage = "URL copied!"; - statusClass = "success"; - StateHasChanged(); + ShowStatus("URL copied!", "success"); } } @@ -383,9 +413,7 @@ if (DevTunnelService.AccessToken != null) { await Microsoft.Maui.ApplicationModel.DataTransfer.Clipboard.SetTextAsync(DevTunnelService.AccessToken); - statusMessage = "Token copied!"; - statusClass = "success"; - StateHasChanged(); + ShowStatus("Token copied!", "success"); } } @@ -398,12 +426,8 @@ serverAlive = success; starting = false; - if (success) - statusMessage = $"Server started on port {settings.Port}"; - else - statusMessage = "Failed to start server"; - statusClass = success ? "success" : "error"; - StateHasChanged(); + ShowStatus(success ? $"Server started on port {settings.Port}" : "Failed to start server", + success ? "success" : "error"); } private void StopServer() @@ -412,45 +436,35 @@ DevTunnelService.Stop(); ServerManager.StopServer(); serverAlive = false; - statusMessage = "Server stopped"; - statusClass = ""; - StateHasChanged(); + ShowStatus("Server stopped", "success"); } private async Task SaveAndApply() { if (settings.Mode == ConnectionMode.Persistent && !serverAlive) { - statusMessage = "Start the persistent server first"; - statusClass = "error"; - StateHasChanged(); + ShowStatus("Start the persistent server first", "error", 5000); return; } if (settings.Mode == ConnectionMode.Remote && string.IsNullOrWhiteSpace(settings.RemoteUrl)) { - statusMessage = "Enter a remote server URL"; - statusClass = "error"; - StateHasChanged(); + ShowStatus("Enter a remote server URL", "error", 5000); return; } settings.Save(); - statusMessage = "Settings saved. Reconnecting..."; - statusClass = ""; - StateHasChanged(); + ShowStatus("Settings saved. Reconnecting...", "", 0); try { await CopilotService.ReconnectAsync(settings); - statusMessage = "Connected!"; - statusClass = "success"; + ShowStatus("Connected!", "success"); + Nav.NavigateTo("/"); } catch (Exception ex) { - statusMessage = $"Connection failed: {ex.Message}"; - statusClass = "error"; + ShowStatus($"Connection failed: {ex.Message}", "error", 8000); } - StateHasChanged(); } } diff --git a/Components/Pages/Settings.razor.css b/Components/Pages/Settings.razor.css index 543b47855b..2d9b952ae7 100644 --- a/Components/Pages/Settings.razor.css +++ b/Components/Pages/Settings.razor.css @@ -7,52 +7,61 @@ } .settings-header h2 { - margin: 0 0 1.5rem 0; - font-size: 1.6rem; + margin: 0; + font-size: var(--type-large-title); } -.settings-section { - background: rgba(255,255,255,0.05); - border: 1px solid rgba(255,255,255,0.1); - border-radius: 12px; - padding: 1.25rem; - margin-bottom: 1rem; +.header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; } -.settings-section h3 { - margin: 0 0 1rem 0; - font-size: 1.15rem; - color: rgba(255,255,255,0.9); +.connection-badge { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.85rem; + border-radius: 20px; + font-size: var(--type-callout); + font-weight: 500; + white-space: nowrap; } -.settings-section.info { - background: rgba(59, 130, 246, 0.08); - border-color: rgba(59, 130, 246, 0.2); +.connection-badge.connected { + background: rgba(74, 222, 128, 0.12); + border: 1px solid rgba(74, 222, 128, 0.3); + color: #4ade80; } -.settings-section.info p { - color: rgba(255,255,255,0.6); - margin: 0.5rem 0; +.connection-badge.disconnected { + background: rgba(248, 113, 113, 0.12); + border: 1px solid rgba(248, 113, 113, 0.3); + color: #f87171; } -.settings-section.info code { - display: block; - background: rgba(0,0,0,0.3); - padding: 0.5rem 0.75rem; - border-radius: 6px; - font-family: monospace; - color: #60a5fa; - margin: 0.5rem 0; +.badge-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 6px currentColor; } -.settings-section.info ul { - color: rgba(255,255,255,0.6); - padding-left: 1.25rem; - margin: 0.5rem 0 0; +.settings-section { + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 12px; + padding: 1.25rem; + margin-bottom: 1rem; } -.settings-section.info li { - margin: 0.25rem 0; +.settings-section h3 { + margin: 0 0 1rem 0; + font-size: var(--type-title2); + color: rgba(255,255,255,0.9); } .mode-cards { @@ -86,13 +95,13 @@ } .mode-title { - font-size: 1.1rem; + font-size: var(--type-title2); font-weight: 600; margin-bottom: 0.3rem; } .mode-desc { - font-size: 0.85rem; + font-size: var(--type-body); color: rgba(255,255,255,0.5); } @@ -106,7 +115,7 @@ .form-row label { min-width: 60px; color: rgba(255,255,255,0.7); - font-size: 0.95rem; + font-size: var(--type-body); } .form-input { @@ -117,7 +126,7 @@ border-radius: 6px; background: rgba(255,255,255,0.08); color: #a0b4cc; - font-size: 0.95rem; + font-size: var(--type-body); } .form-input:focus { @@ -154,7 +163,7 @@ background: transparent; color: rgba(255,255,255,0.6); cursor: pointer; - font-size: 0.85rem; + font-size: var(--type-body); } .check-btn:hover:not(:disabled) { @@ -173,7 +182,7 @@ padding: 0.5rem 1rem; border: none; border-radius: 6px; - font-size: 0.95rem; + font-size: var(--type-body); cursor: pointer; font-weight: 500; } @@ -202,43 +211,69 @@ } .action-hint { - font-size: 0.8rem; + font-size: var(--type-callout); color: rgba(255,255,255,0.3); font-family: monospace; } -.save-row { +.save-section { display: flex; align-items: center; gap: 1rem; + margin-bottom: 1rem; } .save-btn { - padding: 0.6rem 1.5rem; - border: none; + padding: 0.55rem 1.4rem; + border: 1px solid rgba(59, 130, 246, 0.5); border-radius: 8px; - background: #3b82f6; - color: #a0b4cc; - font-size: 1rem; + background: rgba(59, 130, 246, 0.15); + color: #60a5fa; + font-size: var(--type-body); font-weight: 500; cursor: pointer; + transition: all 0.2s ease; } .save-btn:hover { - background: #2563eb; + background: rgba(59, 130, 246, 0.25); + border-color: #3b82f6; } -.save-status { - font-size: 0.9rem; - color: rgba(255,255,255,0.6); +.status-toast { + position: fixed; + bottom: 2rem; + left: 50%; + transform: translateX(-50%); + padding: 0.5rem 1.2rem; + border-radius: 20px; + font-size: var(--type-body); + font-weight: 500; + color: #c8d6e5; + background: rgba(30, 35, 50, 0.95); + border: 1px solid rgba(255,255,255,0.1); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + box-shadow: 0 4px 20px rgba(0,0,0,0.4); + z-index: 1000; + animation: toast-in 0.3s ease; + pointer-events: none; + white-space: nowrap; } -.save-status.success { +.status-toast.success { color: #48bb78; + border-color: rgba(72, 187, 120, 0.3); } -.save-status.error { +.status-toast.error { color: #ef4444; + border-color: rgba(239, 68, 68, 0.3); +} + +@keyframes toast-in { + from { opacity: 0; transform: translateX(-50%) translateY(10px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } } .server-controls { @@ -264,7 +299,7 @@ } .pid-label { - font-size: 0.8rem; + font-size: var(--type-callout); color: rgba(255,255,255,0.4); font-family: monospace; } @@ -286,7 +321,7 @@ border-radius: 6px; background: rgba(255,255,255,0.08); color: #a0b4cc; - font-size: 0.95rem; + font-size: var(--type-body); } .port-input input:focus { @@ -303,18 +338,20 @@ padding: 0.5rem 1rem; border: none; border-radius: 6px; - font-size: 0.95rem; + font-size: var(--type-body); cursor: pointer; font-weight: 500; } .start-btn { - background: #48bb78; - color: #a0b4cc; + background: transparent; + border: 1px solid rgba(72, 187, 120, 0.5); + color: #48bb78; } .start-btn:hover:not(:disabled) { - background: #38a169; + background: rgba(72, 187, 120, 0.15); + border-color: #48bb78; } .start-btn:disabled { @@ -323,12 +360,14 @@ } .stop-btn { - background: rgba(239, 68, 68, 0.8); - color: #a0b4cc; + background: transparent; + border: 1px solid rgba(239, 68, 68, 0.5); + color: #f87171; } .stop-btn:hover { - background: #ef4444; + background: rgba(239, 68, 68, 0.15); + border-color: #ef4444; } .tunnel-warning { @@ -341,7 +380,7 @@ .tunnel-warning p { margin: 0.25rem 0; color: rgba(255,255,255,0.7); - font-size: 0.9rem; + font-size: var(--type-body); } .tunnel-warning code { @@ -353,7 +392,7 @@ } .tunnel-warning .hint { - font-size: 0.8rem; + font-size: var(--type-callout); color: rgba(255,255,255,0.4); } @@ -365,7 +404,7 @@ .tunnel-error { color: #ef4444; - font-size: 0.85rem; + font-size: var(--type-body); margin: 0; } @@ -379,9 +418,10 @@ display: flex; align-items: center; gap: 0.5rem; - padding: 0.5rem 0.75rem; - background: rgba(0,0,0,0.3); - border-radius: 6px; + padding: 0.6rem 0.85rem; + background: rgba(0,0,0,0.25); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 8px; } .tunnel-url code { @@ -393,16 +433,18 @@ .tunnel-token { display: flex; - align-items: center; + align-items: flex-start; gap: 0.5rem; - padding: 0.5rem 0.75rem; - background: rgba(0,0,0,0.3); - border-radius: 6px; + padding: 0.6rem 0.85rem; + background: rgba(0,0,0,0.25); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 8px; + flex-wrap: wrap; } .tunnel-token label { color: rgba(255,255,255,0.6); - font-size: 0.85rem; + font-size: var(--type-body); white-space: nowrap; } @@ -410,10 +452,9 @@ flex: 1; font-family: monospace; color: rgba(255,255,255,0.4); - font-size: 0.75rem; - overflow: hidden; - text-overflow: ellipsis; + font-size: var(--type-caption1); word-break: break-all; + white-space: normal; transition: filter 0.2s ease; } @@ -428,16 +469,19 @@ .copy-btn { background: transparent; - border: 1px solid rgba(255,255,255,0.2); - border-radius: 4px; - padding: 0.2rem 0.5rem; + border: 1px solid rgba(255,255,255,0.15); + border-radius: 6px; + padding: 0.3rem 0.5rem; cursor: pointer; - font-size: 0.9rem; - color: rgba(255,255,255,0.6); + font-size: var(--type-body); + color: rgba(255,255,255,0.5); + transition: all 0.15s ease; } .copy-btn:hover { - border-color: rgba(255,255,255,0.4); + border-color: rgba(255,255,255,0.35); + color: rgba(255,255,255,0.8); + background: rgba(255,255,255,0.05); } .qr-code { @@ -455,7 +499,7 @@ } .qr-hint { - font-size: 0.8rem; + font-size: var(--type-callout); color: rgba(255,255,255,0.4); margin: 0.5rem 0 0; } @@ -473,7 +517,7 @@ .mode-hint { color: rgba(255,255,255,0.5); - font-size: 0.9rem; + font-size: var(--type-body); margin: 0; } @@ -495,7 +539,7 @@ .tunnel-status-text { color: rgba(255,255,255,0.5); - font-size: 0.9rem; + font-size: var(--type-body); margin: 0; } @@ -506,7 +550,7 @@ border-radius: 10px; background: rgba(59, 130, 246, 0.08); color: #60a5fa; - font-size: 1.1rem; + font-size: var(--type-title2); font-weight: 500; cursor: pointer; transition: all 0.2s ease; @@ -554,3 +598,113 @@ color: #4ea8d1; line-height: 1.3; } + +/* === Mobile: compact settings layout === */ +@media (max-width: 640px) { + .settings-page { + padding: 0.75rem; + } + + .settings-header h2 { + font-size: var(--type-title2); + } + + .header-row { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + margin-bottom: 0.75rem; + } + + .connection-badge { + font-size: var(--type-footnote); + padding: 0.25rem 0.6rem; + } + + .settings-section { + padding: 0.75rem; + margin-bottom: 0.6rem; + border-radius: 10px; + } + + .settings-section h3 { + font-size: var(--type-body); + margin-bottom: 0.6rem; + } + + .mode-cards { + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + } + + .mode-card { + padding: 0.6rem; + border-radius: 8px; + } + + .mode-icon { + font-size: 1.2rem; + margin-bottom: 0.25rem; + } + + .mode-icon svg { + width: 20px; + height: 20px; + } + + .mode-title { + font-size: var(--type-callout); + margin-bottom: 0.15rem; + } + + .mode-desc { + font-size: var(--type-footnote); + line-height: 1.3; + } + + .mode-hint { + font-size: var(--type-callout); + } + + .scan-btn { + padding: 0.6rem; + font-size: var(--type-body); + border-radius: 8px; + } + + .url-input { + flex-direction: column; + align-items: stretch; + gap: 0.25rem; + } + + .url-input label { + font-size: var(--type-callout); + } + + .form-input { + font-size: var(--type-callout); + padding: 0.35rem 0.5rem; + } + + .save-section { + padding: 0.75rem; + } + + .save-btn { + font-size: var(--type-body); + padding: 0.5rem 1.25rem; + width: 100%; + } + + .server-status { + padding: 0.35rem 0.5rem; + font-size: var(--type-callout); + gap: 0.5rem; + } + + .start-btn, .stop-btn { + font-size: var(--type-callout); + padding: 0.4rem 0.75rem; + } +} diff --git a/MainPage.xaml b/MainPage.xaml index 3f2a335e26..761e8e13ae 100644 --- a/MainPage.xaml +++ b/MainPage.xaml @@ -1,6 +1,8 @@ diff --git a/MainPage.xaml.cs b/MainPage.xaml.cs index 1ee0ddb469..4f3ccddd4e 100644 --- a/MainPage.xaml.cs +++ b/MainPage.xaml.cs @@ -18,9 +18,21 @@ public MainPage() #if ANDROID blazorWebView.BlazorWebViewInitialized += OnBlazorWebViewInitialized; +#elif IOS + blazorWebView.BlazorWebViewInitialized += OnBlazorWebViewInitializediOS; #endif } +#if IOS + private void OnBlazorWebViewInitializediOS(object? sender, BlazorWebViewInitializedEventArgs e) + { + var wkWebView = e.WebView; + wkWebView.Opaque = false; + wkWebView.BackgroundColor = UIKit.UIColor.FromRGB(0x1a, 0x1a, 0x2e); + wkWebView.ScrollView.BackgroundColor = UIKit.UIColor.FromRGB(0x1a, 0x1a, 0x2e); + } +#endif + #if ANDROID private void OnBlazorWebViewInitialized(object? sender, BlazorWebViewInitializedEventArgs e) { diff --git a/Models/ConnectionSettings.cs b/Models/ConnectionSettings.cs index 9a1656ad96..f965c9aa1c 100644 --- a/Models/ConnectionSettings.cs +++ b/Models/ConnectionSettings.cs @@ -7,7 +7,8 @@ public enum ConnectionMode { 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) + Remote, // Connect to a remote server via URL (e.g. DevTunnel) + Demo // Local mock mode for testing chat UI without a real connection } public class ConnectionSettings diff --git a/Platforms/iOS/Info.plist b/Platforms/iOS/Info.plist index 2910079de2..edb6bb174e 100644 --- a/Platforms/iOS/Info.plist +++ b/Platforms/iOS/Info.plist @@ -30,5 +30,9 @@ Assets.xcassets/appicon.appiconset NSCameraUsageDescription AutoPilot uses the camera to scan QR codes for connecting to remote servers. + UIStatusBarStyle + UIStatusBarStyleLightContent + UIViewControllerBasedStatusBarAppearance + diff --git a/Resources/AppIcon/iOSAssets.car b/Resources/AppIcon/iOSAssets.car new file mode 100644 index 0000000000..b230f6670f Binary files /dev/null and b/Resources/AppIcon/iOSAssets.car differ diff --git a/Resources/Splash/splash.png b/Resources/Splash/splash.png new file mode 100644 index 0000000000..725865aacd Binary files /dev/null and b/Resources/Splash/splash.png differ diff --git a/Services/CopilotService.cs b/Services/CopilotService.cs index 277daad233..f15547eeb3 100644 --- a/Services/CopilotService.cs +++ b/Services/CopilotService.cs @@ -12,6 +12,7 @@ public class CopilotService : IAsyncDisposable private readonly ChatDatabase _chatDb; private readonly ServerManager _serverManager; private readonly WsBridgeClient _bridgeClient; + private readonly DemoService _demoService; private CopilotClient? _client; private string? _activeSessionName; private SynchronizationContext? _syncContext; @@ -86,6 +87,7 @@ private static string FindProjectDir() public bool IsInitialized { get; private set; } public bool NeedsConfiguration { get; private set; } public bool IsRemoteMode { get; private set; } + public bool IsDemoMode { get; private set; } public string? ActiveSessionName => _activeSessionName; public ChatDatabase ChatDb => _chatDb; public ConnectionMode CurrentMode { get; private set; } = ConnectionMode.Embedded; @@ -95,6 +97,7 @@ public CopilotService(ChatDatabase chatDb, ServerManager serverManager, WsBridge _chatDb = chatDb; _serverManager = serverManager; _bridgeClient = bridgeClient; + _demoService = new DemoService(); } // Debug info @@ -170,6 +173,13 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) return; } + // Demo mode: local mock responses, no network needed + if (settings.Mode == ConnectionMode.Demo) + { + InitializeDemo(); + return; + } + #if ANDROID // Android can't run Copilot CLI locally — must connect to remote server settings.Mode = ConnectionMode.Persistent; @@ -308,6 +318,58 @@ private async Task InitializeRemoteAsync(ConnectionSettings settings, Cancellati OnStateChanged?.Invoke(); } + /// + /// Initialize in Demo mode: wire up DemoService events for local mock responses. + /// + private void InitializeDemo() + { + Debug("Demo mode: initializing with mock responses"); + + _demoService.OnStateChanged += () => InvokeOnUI(() => OnStateChanged?.Invoke()); + _demoService.OnContentReceived += (s, c) => + { + // Accumulate response in SessionState for history + if (_sessions.TryGetValue(s, out var state)) + state.CurrentResponse.Append(c); + InvokeOnUI(() => OnContentReceived?.Invoke(s, c)); + }; + _demoService.OnToolStarted += (s, tool, id) => + { + if (_sessions.TryGetValue(s, out var state)) + { + FlushCurrentResponse(state); + state.Info.History.Add(ChatMessage.ToolCallMessage(tool, id)); + } + InvokeOnUI(() => OnToolStarted?.Invoke(s, tool, id, null)); + }; + _demoService.OnToolCompleted += (s, id, result, success) => + { + if (_sessions.TryGetValue(s, out var state)) + { + var toolMsg = state.Info.History.LastOrDefault(m => m.ToolCallId == id); + if (toolMsg != null) { toolMsg.IsComplete = true; toolMsg.IsSuccess = success; toolMsg.Content = result; } + } + InvokeOnUI(() => OnToolCompleted?.Invoke(s, id, result, success)); + }; + _demoService.OnIntentChanged += (s, i) => InvokeOnUI(() => OnIntentChanged?.Invoke(s, i)); + _demoService.OnTurnStart += (s) => InvokeOnUI(() => OnTurnStart?.Invoke(s)); + _demoService.OnTurnEnd += (s) => + { + // Flush accumulated response into history (mirrors CompleteResponse) + if (_sessions.TryGetValue(s, out var state)) + { + CompleteResponse(state); + } + InvokeOnUI(() => OnTurnEnd?.Invoke(s)); + }; + + IsInitialized = true; + IsDemoMode = true; + NeedsConfiguration = false; + Debug("Demo mode initialized"); + OnStateChanged?.Invoke(); + } + /// /// Sync remote session list from WsBridgeClient into our local _sessions dictionary. /// @@ -339,10 +401,12 @@ private void SyncRemoteSessions() Info = info }; } - // Update processing state + // Update processing state — don't overwrite local 'true' with remote 'false' + // (race: we sent a message but server hasn't started processing yet) if (_sessions.TryGetValue(rs.Name, out var state)) { - state.Info.IsProcessing = rs.IsProcessing; + if (rs.IsProcessing) + state.Info.IsProcessing = true; state.Info.MessageCount = rs.MessageCount; } } @@ -356,13 +420,17 @@ private void SyncRemoteSessions() } // Sync history from WsBridgeClient cache + // Don't overwrite if local history has messages not yet reflected by server foreach (var (name, messages) in _bridgeClient.SessionHistories) { if (_sessions.TryGetValue(name, out var s)) { - Debug($"SyncRemoteSessions: Syncing {messages.Count} messages for '{name}'"); - s.Info.History.Clear(); - s.Info.History.AddRange(messages); + if (messages.Count >= s.Info.History.Count) + { + Debug($"SyncRemoteSessions: Syncing {messages.Count} messages for '{name}'"); + s.Info.History.Clear(); + s.Info.History.AddRange(messages); + } } } @@ -400,9 +468,17 @@ public async Task ReconnectAsync(ConnectionSettings settings, CancellationToken IsInitialized = false; IsRemoteMode = false; + IsDemoMode = false; CurrentMode = settings.Mode; OnStateChanged?.Invoke(); + // Demo mode: local mock responses + if (settings.Mode == ConnectionMode.Demo) + { + InitializeDemo(); + return; + } + // Remote mode uses WsBridgeClient state-sync if (settings.Mode == ConnectionMode.Remote && !string.IsNullOrWhiteSpace(settings.RemoteUrl)) { @@ -881,6 +957,17 @@ public async Task ResumeSessionAsync(string sessionId, string public async Task CreateSessionAsync(string name, string? model = null, string? workingDirectory = null, CancellationToken cancellationToken = default) { + // In demo mode, create a local mock session + if (IsDemoMode) + { + var demoInfo = _demoService.CreateSession(name, model); + var demoState = new SessionState { Session = null!, Info = demoInfo }; + _sessions[name] = demoState; + _activeSessionName ??= name; + OnStateChanged?.Invoke(); + return demoInfo; + } + // In remote mode, delegate to WsBridgeClient if (IsRemoteMode) { @@ -1028,7 +1115,9 @@ void Invoke(Action action) var toolInput = ExtractToolInput(toolStart.Data); if (!FilteredTools.Contains(startToolName)) { - // Add to session history + // Flush any accumulated assistant text before adding tool message + FlushCurrentResponse(state); + var toolMsg = ChatMessage.ToolCallMessage(startToolName, startCallId, toolInput); state.Info.History.Add(toolMsg); @@ -1199,6 +1288,70 @@ private static string FormatToolResult(object? result) return null; } + private void TryAttachImages(MessageOptions options, List imagePaths) + { + try + { + var sdkAssembly = typeof(MessageOptions).Assembly; + var attachItemType = sdkAssembly.GetType("GitHub.Copilot.SDK.UserMessageDataAttachmentsItem"); + var fileType = sdkAssembly.GetType("GitHub.Copilot.SDK.UserMessageDataAttachmentsItemFile"); + if (attachItemType == null || fileType == null) + { + Debug("SDK attachment types not found, falling back to path-in-prompt"); + return; + } + + var items = new System.Collections.Generic.List(); + foreach (var path in imagePaths) + { + if (!File.Exists(path)) continue; + + // Create UserMessageDataAttachmentsItemFile with FilePath + var fileObj = Activator.CreateInstance(fileType); + fileType.GetProperty("FilePath")?.SetValue(fileObj, path); + + // Create UserMessageDataAttachmentsItem with File + var itemObj = Activator.CreateInstance(attachItemType); + attachItemType.GetProperty("File")?.SetValue(itemObj, fileObj); + + items.Add(itemObj!); + } + + if (items.Count == 0) return; + + // Create typed list and set on MessageOptions.Attachments + var listType = typeof(List<>).MakeGenericType(attachItemType); + var typedList = Activator.CreateInstance(listType); + var addMethod = listType.GetMethod("Add"); + foreach (var item in items) + addMethod?.Invoke(typedList, new[] { item }); + + typeof(MessageOptions).GetProperty("Attachments")?.SetValue(options, typedList); + Debug($"Attached {items.Count} image(s) via SDK"); + } + catch (Exception ex) + { + Debug($"Failed to attach images via SDK: {ex.Message}"); + } + } + + /// Flush accumulated assistant text to history without ending the turn. + private void FlushCurrentResponse(SessionState state) + { + var text = state.CurrentResponse.ToString(); + if (string.IsNullOrEmpty(text)) return; + + var msg = new ChatMessage("assistant", text, DateTime.Now); + state.Info.History.Add(msg); + state.Info.MessageCount = state.Info.History.Count; + + if (!string.IsNullOrEmpty(state.Info.SessionId)) + _ = _chatDb.AddMessageAsync(state.Info.SessionId, msg); + + state.CurrentResponse.Clear(); + state.HasReceivedDeltasThisTurn = false; + } + private void CompleteResponse(SessionState state) { if (!state.Info.IsProcessing) return; // Already completed (e.g. timeout) @@ -1245,8 +1398,21 @@ private void CompleteResponse(SessionState state) } } - public async Task SendPromptAsync(string sessionName, string prompt, CancellationToken cancellationToken = default) + public async Task SendPromptAsync(string sessionName, string prompt, List? imagePaths = null, CancellationToken cancellationToken = default) { + // In demo mode, simulate a response locally + if (IsDemoMode) + { + if (!_sessions.TryGetValue(sessionName, out var demoState)) + throw new InvalidOperationException($"Session '{sessionName}' not found."); + demoState.Info.History.Add(ChatMessage.UserMessage(prompt)); + demoState.Info.MessageCount = demoState.Info.History.Count; + demoState.CurrentResponse.Clear(); + OnStateChanged?.Invoke(); + _ = Task.Run(() => _demoService.SimulateResponseAsync(sessionName, prompt, _syncContext, cancellationToken)); + return ""; + } + // In remote mode, delegate to WsBridgeClient if (IsRemoteMode) { @@ -1284,10 +1450,15 @@ public async Task SendPromptAsync(string sessionName, string prompt, Can try { - await state.Session.SendAsync(new MessageOptions + var messageOptions = new MessageOptions { Prompt = prompt }; + + // Attach images via SDK if available + if (imagePaths != null && imagePaths.Count > 0) { - Prompt = prompt - }, cancellationToken); + TryAttachImages(messageOptions, imagePaths); + } + + await state.Session.SendAsync(messageOptions, cancellationToken); } catch (Exception ex) { diff --git a/Services/DemoService.cs b/Services/DemoService.cs new file mode 100644 index 0000000000..0701e93238 --- /dev/null +++ b/Services/DemoService.cs @@ -0,0 +1,147 @@ +using System.Collections.Concurrent; +using AutoPilot.App.Models; + +namespace AutoPilot.App.Services; + +/// +/// Simulates chat responses for Demo mode — no network connection needed. +/// Fires the same events as CopilotService for UI testing. +/// +public class DemoService +{ + private readonly ConcurrentDictionary _sessions = new(); + private string? _activeSessionName; + private int _sessionCounter; + + public event Action? OnStateChanged; + public event Action? OnContentReceived; + public event Action? OnToolStarted; + public event Action? OnToolCompleted; + public event Action? OnIntentChanged; + public event Action? OnTurnStart; + public event Action? OnTurnEnd; + + public IReadOnlyDictionary Sessions => _sessions; + public string? ActiveSessionName => _activeSessionName; + + public AgentSessionInfo CreateSession(string name, string? model = null) + { + var info = new AgentSessionInfo + { + Name = name, + Model = model ?? "demo-model", + CreatedAt = DateTime.Now, + SessionId = $"demo-{Interlocked.Increment(ref _sessionCounter)}" + }; + _sessions[name] = info; + _activeSessionName ??= name; + OnStateChanged?.Invoke(); + return info; + } + + public bool TryGetSession(string name, out AgentSessionInfo? info) => + _sessions.TryGetValue(name, out info); + + public void SetActiveSession(string name) + { + if (_sessions.ContainsKey(name)) + _activeSessionName = name; + } + + public async Task SimulateResponseAsync(string sessionName, string userPrompt, SynchronizationContext? syncContext, CancellationToken ct = default) + { + if (!_sessions.TryGetValue(sessionName, out var session)) return; + + session.IsProcessing = true; + Post(syncContext, () => OnStateChanged?.Invoke()); + Post(syncContext, () => OnTurnStart?.Invoke(sessionName)); + + // Small pause before responding + await Task.Delay(300, ct); + + // Decide whether to simulate a tool call (~30% chance, skip for short messages) + var shouldSimulateTool = userPrompt.Length > 20 && Random.Shared.NextDouble() < 0.3; + + if (shouldSimulateTool) + { + var toolName = PickRandomTool(); + var callId = $"call_{Guid.NewGuid():N}"[..12]; + + Post(syncContext, () => OnIntentChanged?.Invoke(sessionName, $"Using {toolName}...")); + Post(syncContext, () => OnToolStarted?.Invoke(sessionName, toolName, callId)); + + await Task.Delay(Random.Shared.Next(500, 1500), ct); + + Post(syncContext, () => OnToolCompleted?.Invoke(sessionName, callId, "Done", true)); + + await Task.Delay(200, ct); + } + + Post(syncContext, () => OnIntentChanged?.Invoke(sessionName, "Responding...")); + + // Stream the response word by word + var response = GenerateResponse(userPrompt); + var words = response.Split(' '); + bool first = true; + + foreach (var word in words) + { + ct.ThrowIfCancellationRequested(); + var chunk = (first ? "" : " ") + word; + first = false; + Post(syncContext, () => OnContentReceived?.Invoke(sessionName, chunk)); + await Task.Delay(Random.Shared.Next(20, 80), ct); + } + + session.IsProcessing = false; + session.MessageCount = session.History.Count; + + Post(syncContext, () => OnTurnEnd?.Invoke(sessionName)); + Post(syncContext, () => OnStateChanged?.Invoke()); + } + + private static void Post(SynchronizationContext? ctx, Action action) + { + if (ctx != null) ctx.Post(_ => action(), null); + else action(); + } + + private static string PickRandomTool() => + Random.Shared.Next(5) switch + { + 0 => "bash", + 1 => "grep", + 2 => "view", + 3 => "edit", + _ => "glob" + }; + + private static string GenerateResponse(string prompt) + { + var lower = prompt.ToLowerInvariant(); + + if (lower.Contains("hello") || lower.Contains("hi ") || lower.StartsWith("hi")) + return "Hey there! 👋 This is a demo response. The chat UI is working — you can test sending messages, scrolling, and all the visual elements without needing a real connection."; + + if (lower.Contains("help")) + return "I'm running in **Demo mode**, so I'm just simulating responses. In a real session I'd have access to tools like `bash`, `grep`, `view`, and `edit` to help you with coding tasks. Switch to Remote mode and scan a QR code to connect to a real Copilot instance."; + + if (lower.Contains("code") || lower.Contains("function") || lower.Contains("implement")) + return "Here's a simulated code snippet:\n\n```csharp\npublic class Example\n{\n public string Greet(string name)\n {\n return $\"Hello, {name}!\";\n }\n}\n```\n\nThis is just a demo — in a real session I'd analyze your actual codebase and generate relevant code."; + + if (lower.Contains("test")) + return "Demo mode is working! ✅ You can verify:\n- Message sending and receiving\n- Streaming text appearance\n- Tool call indicators\n- Markdown rendering\n- Scroll behavior\n\nEverything runs locally with no network needed."; + + var responses = new[] + { + $"Got your message: \"{Truncate(prompt, 50)}\". This is a simulated response in Demo mode. The full chat pipeline is working — messages flow through the same rendering path as real responses.", + $"Demo mode echo: I received \"{Truncate(prompt, 40)}\". In a real session, I'd process this with the Copilot SDK and use tools to help. The UI you're seeing is identical to the real experience.", + $"Thanks for testing! Your message was {prompt.Length} characters long. Demo mode simulates streaming responses with realistic timing so you can verify the chat experience works correctly on this device." + }; + + return responses[Random.Shared.Next(responses.Length)]; + } + + private static string Truncate(string s, int max) => + s.Length <= max ? s : s[..max] + "…"; +} diff --git a/Services/DevTunnelService.cs b/Services/DevTunnelService.cs index 5b0d366987..de507cb8d3 100644 --- a/Services/DevTunnelService.cs +++ b/Services/DevTunnelService.cs @@ -41,6 +41,21 @@ public DevTunnelService(WsBridgeServer bridge, CopilotService copilot) public event Action? OnStateChanged; + private static string ResolveDevTunnel() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var candidates = new[] { + Path.Combine(home, "bin", "devtunnel"), + Path.Combine(home, ".local", "bin", "devtunnel"), + "/usr/local/bin/devtunnel", + "/opt/homebrew/bin/devtunnel", + "devtunnel" + }; + foreach (var c in candidates) + if (c != "devtunnel" && File.Exists(c)) return c; + return "devtunnel"; + } + [GeneratedRegex(@"(https?://\S+\.devtunnels\.ms\S*)", RegexOptions.IgnoreCase)] private static partial Regex TunnelUrlRegex(); @@ -63,7 +78,7 @@ public static bool IsCliAvailable() { var psi = new ProcessStartInfo { - FileName = "devtunnel", + FileName = ResolveDevTunnel(), Arguments = "--version", UseShellExecute = false, RedirectStandardOutput = true, @@ -86,7 +101,7 @@ public async Task IsLoggedInAsync() { var psi = new ProcessStartInfo { - FileName = "devtunnel", + FileName = ResolveDevTunnel(), Arguments = "user show", UseShellExecute = false, RedirectStandardOutput = true, @@ -114,7 +129,7 @@ public async Task LoginAsync() { var psi = new ProcessStartInfo { - FileName = "devtunnel", + FileName = ResolveDevTunnel(), Arguments = "user login -g", UseShellExecute = false, RedirectStandardOutput = true, @@ -247,7 +262,7 @@ private async Task TryHostTunnelAsync(ConnectionSettings settings) var psi = new ProcessStartInfo { - FileName = "devtunnel", + FileName = ResolveDevTunnel(), Arguments = hostArgs, UseShellExecute = false, RedirectStandardOutput = true, @@ -360,7 +375,7 @@ private void TryExtractInfo(string line, TaskCompletionSource urlFound) { var psi = new ProcessStartInfo { - FileName = "devtunnel", + FileName = ResolveDevTunnel(), Arguments = $"token {tunnelArg} --scopes connect", UseShellExecute = false, RedirectStandardOutput = true, diff --git a/Services/WsBridgeServer.cs b/Services/WsBridgeServer.cs index 3fc6288192..ce09a92937 100644 --- a/Services/WsBridgeServer.cs +++ b/Services/WsBridgeServer.cs @@ -265,7 +265,7 @@ await SendToClientAsync(clientId, ws, if (sendReq != null) { Console.WriteLine($"[WsBridge] Client sending message to '{sendReq.SessionName}'"); - await _copilot.SendPromptAsync(sendReq.SessionName, sendReq.Message, ct); + await _copilot.SendPromptAsync(sendReq.SessionName, sendReq.Message, cancellationToken: ct); } break; diff --git a/wwwroot/app.css b/wwwroot/app.css index 47f6d9340f..591a35ea21 100644 --- a/wwwroot/app.css +++ b/wwwroot/app.css @@ -1,5 +1,50 @@ +/* Apple iOS Dynamic Type scale (relative to --app-font-size base) */ +:root { + --type-large-title: 1.7rem; /* 34px — page titles */ + --type-title1: 1.4rem; /* 28px — major section headers */ + --type-title2: 1.1rem; /* 22px — section headers */ + --type-title3: 1.0rem; /* 20px — sub-section headers, prominent controls */ + --type-headline: 0.85rem; /* 17px — emphasized body text (use font-weight:600) */ + --type-body: 0.85rem; /* 17px — primary content */ + --type-callout: 0.8rem; /* 16px — secondary content, labels */ + --type-subhead: 0.75rem; /* 15px — tertiary content */ + --type-footnote: 0.65rem; /* 13px — timestamps, metadata */ + --type-caption1: 0.6rem; /* 12px — badges, small labels */ + --type-caption2: 0.55rem; /* 11px — minimum readable size */ +} + +/* Mobile: scale down for phone screens */ +@media (max-width: 640px) { + :root { + --type-large-title: 1.55rem; + --type-title1: 1.25rem; + --type-title2: 1.0rem; + --type-title3: 0.9rem; + --type-headline: 0.8rem; + --type-body: 0.8rem; + --type-callout: 0.75rem; + --type-subhead: 0.7rem; + --type-footnote: 0.6rem; + --type-caption1: 0.55rem; + --type-caption2: 0.5rem; + } +} + +/* Mac Catalyst / desktop: bump scale to compensate for 77% Catalyst scaling */ +@media (min-width: 641px) { + :root { + --type-body: 0.95rem; + --type-headline: 0.95rem; + --type-callout: 0.85rem; + --type-subhead: 0.8rem; + --type-footnote: 0.75rem; + --type-caption1: 0.65rem; + --type-caption2: 0.6rem; + } +} + html, body { - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-family: -apple-system, 'SF Pro Text', 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: var(--app-font-size, 20px); color: #a0b4cc; } @@ -106,7 +151,7 @@ h1:focus { position: sticky; top: 0; height: env(safe-area-inset-top); - background-color: #f7f7f7; + background-color: #1a1a2e; width: 100%; z-index: 1; } @@ -164,7 +209,7 @@ h1:focus { left: 10px; max-width: none; padding: 10px 14px; - font-size: 0.8rem; + font-size: var(--type-callout); border-radius: 8px; transform: translateY(-20px); } diff --git a/wwwroot/index.html b/wwwroot/index.html index 908bf15b52..f6a0bde42e 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -3,7 +3,7 @@ - AutoPilot.App + AutoPilot