using System.Collections.ObjectModel; using System.Drawing; using System.Reflection; using System.Runtime.InteropServices; using LlamaApp.Common; using LlamaApp.HuggingFace; using LlamaApp.Llama; using Microsoft.UI.Dispatching; using Microsoft.UI.Windowing; using Windows.Graphics; using WinRT.Interop; namespace LlamaApp.Views { /// /// Main application shell, repurposed as a single-view system-tray flyout. /// The window is never shown as a normal top-level window: it is styled /// borderless with a Mica backdrop (native Windows 11 flyout look) and only /// ever appears anchored to the tray icon via , /// auto-hiding when it loses activation. This mirrors the macOS menu-bar /// app on Windows while hosting the four-section models panel. /// public sealed partial class MainWindow : Window { // Flyout dimensions, in device-independent pixels (DIPs — the units XAML // layout uses). AppWindow sizes/positions are in PHYSICAL pixels, so these // are scaled by the target monitor's DPI before every Resize/Move — that // keeps the flyout the same logical size on every screen, no matter the // display's scaling (100% / 150% / 200% …). Sized for the single-column // model list + footer; content scrolls if sections overflow. private const int FlyoutWidthDips = 420; private const int FlyoutHeightDips = 560; private const int GWL_EXSTYLE = -20; private const int WS_EX_TOOLWINDOW = 0x00000080; private const uint MONITOR_DEFAULTTONEAREST = 0x00000002; // How long after a deactivation-driven hide a tray click is treated as a // continuation of the click that dismissed the flyout (so it doesn't // bounce straight back open) rather than a fresh "open" request. private const long DeactivateHideGracePeriodMs = 300; // Deactivations arriving within this window after a show are treated as // the OS reclaiming foreground (a background process's Activate() can be // denied foreground, so the previously-active window snatches focus back // immediately) and ignored — without this the reshow would hide itself // straight back, making it look like the flyout never reopens. private const long ShownDeactivationGraceMs = 250; private const int SW_SHOW = 5; private const int SW_HIDE = 0; private bool _configured; private bool _activated; private bool _allowHideOnDeactivate; private long _lastDeactivateHideMs; private long _lastShownMs; private IntPtr _hwnd; /// /// Set by the tray manager when the app is truly exiting so the /// handler lets the window close instead of hiding. /// public bool AllowClose { get; set; } /// /// Raised when the user picks Quit in the footer. Wired by /// App to so the window /// doesn't need a direct reference to the tray-icon owner. /// public event Action? ExitRequested; /// Locally installed models — shown with a run glyph. public ObservableCollection LocalModels { get; } = []; /// Recommended Hub models — shown with a download glyph. public ObservableCollection RecommendedModels { get; } = []; /// /// The remote catalog, fetched once and shared by both sections: the /// Recommended section lists it directly, the Available section uses it /// to enrich server-reported models (display name, params, size, brand /// logo). Resolved before the per-section loaders run. /// private Task> _catalogTask = null!; // // The catalog reshaped into a bare-repo-id → // lookup, collapsing the per-quant duplicates. Built EXACTLY ONCE, as a // continuation over — the awaiters (initial // populate, the per-second poller reconcile, the download-row builders) // all share the same projected task, so the GroupBy/ToDictionary runs // once no matter how many callers race it or how often the 1s poller // fires. Previously re-projected the // catalog on every call, allocating a fresh dictionary each second. // private Task> _catalogByRepoTask = null!; // Re-entry guard for LoadLocalModelsAsync: 0 idle, 1 running. // StateChanged can re-trigger a load while an earlier invocation is // still waiting for the server, so we serialize population passes. private int _loadingLocalModels; // Index of Available rows by the server model id ("repo:quant"), so the // ModelsChanged poller can update each row's load state in place instead // of rebuilding the list every second (which would flicker and lose // click/loading state). Kept in sync wherever LocalModels is mutated. private readonly Dictionary _localByServerId = new(StringComparer.OrdinalIgnoreCase); // Progress watches for downloads the app did not start itself (WebUI / // CLI), keyed by the row they feed. Such a row has no download driver // wiring byte progress, so without a watch it would sit on the // indeterminate ring for the whole download. The poller owns each // watch's lifetime: started when a row enters the downloading state, // canceled when it leaves it. All access happens on the UI thread. private readonly Dictionary _externalDownloadWatches = new(); // Last observed server state, for the once-per-transition crash toast // in LlamaManager_StateChanged (StateChanged fires for every manager // property change, not just status transitions). private LlamaManager.ServerState _lastServerStatus; // Delete-confirmation context: the trash button's attached Flyout opens // automatically on click; LocalModelDelete_Click captures the row's model // and the flyout here so the flyout's Delete button (which carries no // Tag of its own) can act on them and dismiss itself. private ModelItem? _pendingDelete; private Microsoft.UI.Xaml.Controls.Primitives.FlyoutBase? _deleteConfirmFlyout; // Hover fill for the model rows, resolved lazily from the theme resources. // Rows must keep a non-null Background at all times — a null Background // makes the Grid transparent to hit-testing, so PointerEntered would // never fire again after the first exit. private static Microsoft.UI.Xaml.Media.Brush? _rowHoverBrush; private static readonly Microsoft.UI.Xaml.Media.Brush RowRestBrush = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Transparent); public MainWindow() { InitializeComponent(); ConfigureAsFlyout(); // Brand logos: the bundled SVGs rasterize black (currentColor), // which vanishes on the dark theme's Mica — use the white ".light" // variants while the effective theme is dark, and re-resolve the // rows' logos live when the OS theme flips. Must run before // LoadModels() so the initial resolve picks the right variant. var root = (FrameworkElement)Content; ModelItem.UseLightLogos = root.ActualTheme == ElementTheme.Dark; root.ActualThemeChanged += (_, _) => { ModelItem.UseLightLogos = root.ActualTheme == ElementTheme.Dark; ModelItem.ClearLogoCache(); RefreshRowLogos(); }; Closed += MainWindow_Closed; Activated += MainWindow_Activated; LoadModels(); LoadVersionInfo(); UpdateServerStatusUI(); UpdateEmptyState(); _ = LoadAvatarAsync(); // Refresh the footer's llama.cpp version as the binary is // detected/installed. LlamaManager.EnsureLlamaOrDownloadAsync runs // in parallel from App.OnLaunched; its StateChanged fires on the UI // thread, so we can touch the TextBlock directly. LlamaManager.Shared.StateChanged += LlamaManager_StateChanged; // The model-state poller (started by LlamaManager once the server is // Running) fires ModelsChanged roughly every 1s with a fresh /models // snapshot. We reconcile it into the Available rows in place — // flipping play -> indeterminate load ring -> OpenInNewWindow glyph // as the server reports each model's load state. LlamaManager.Shared.ModelsChanged += LlamaManager_ModelsChanged; } // ---- Data ---- /// /// Populates the model lists. The Recommended section comes straight from /// the remote catalog; the Available section is fetched from the running /// llama server's GET /models once it's reachable. Both share a /// single catalog fetch (the Available rows are enriched from it). /// private void LoadModels() { StartCatalogFetch(); _ = LoadRecommendedModelsAsync(); _ = LoadLocalModelsAsync(); } /// /// (Re)fetches the remote catalog and re-projects the repo-id lookup. /// Split out of so the Recommended section's /// Retry button can refetch without touching the Available list. /// private void StartCatalogFetch() { _catalogTask = FetchCatalogAsync(); // Project the fetched catalog into a repo-id lookup exactly once — a // continuation that runs when the fetch completes, shared by every // caller of GetCatalogByRepoAsync. ContinueWith(NotOnFaulted, // TaskScheduler.Default) so an (impossible — FetchCatalogAsync never // faults) fault still yields a usable empty dictionary rather than // a faulted task awaited by callers that don't expect a throw. _catalogByRepoTask = _catalogTask.ContinueWith( t => t.Result .GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase), TaskContinuationOptions.NotOnFaulted | TaskContinuationOptions.RunContinuationsAsynchronously); } /// /// Fetches the remote catalog once into for /// both sections. Never throws — a network failure just yields an empty /// list (the Recommended section stays empty, Available rows aren't /// enriched). /// private async Task> FetchCatalogAsync() { try { return (await Catalog.FetchAsync()).ToList(); } catch (Exception ex) { Log.Warn(ex, "catalog fetch failed; Recommended stays empty"); return []; } } /// /// Lists the locally available (cached) models from the running llama /// server's GET /models endpoint — the authoritative source now /// that is a server client. Waits for the /// server to come up ( /// runs in parallel from App.OnLaunched), then fetches. Each row is /// enriched with catalog metadata (display name, params, size, brand /// logo); the vision flag comes from the server's /// architecture.input_modalities. /// private async Task LoadLocalModelsAsync() { // Only one population pass at a time — LlamaManager_StateChanged can // re-trigger us while an earlier invocation is still waiting for the // server (or after a transient failure cleared up). if (Interlocked.CompareExchange(ref _loadingLocalModels, 1, 0) != 0) return; try { var mgr = LlamaManager.Shared; // Wait for the server to be reachable. A transient Failed here // isn't fatal: StateChanged re-triggers this once Running is // reached, so bail rather than block the full 5 minutes. var deadline = DateTime.UtcNow + TimeSpan.FromMinutes(5); while (mgr.ServerStatus != LlamaManager.ServerState.Running) { if (mgr.State == LlamaManager.InstallState.Failed || mgr.ServerStatus == LlamaManager.ServerState.Failed || DateTime.UtcNow >= deadline) { UpdateEmptyState(); return; } try { await Task.Delay(500); } catch { return; } } // The router answers /health as soon as it binds, but /models // can come back empty for the first second or two while the HF // cache is scanned. Retry briefly so a startup race doesn't pin // the list to "No model yet" forever. IReadOnlyList serverModels = []; var modelDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(15); while (DateTime.UtcNow < modelDeadline) { try { serverModels = await mgr.GetModelsAsync(); } catch (Exception ex) { Log.Debug("GetModels retry failed: " + ex.Message); serverModels = []; } if (serverModels.Count > 0) break; try { await Task.Delay(500); } catch { break; } } await PopulateLocalModelsAsync(serverModels); Log.Info("loaded " + serverModels.Count + " local model(s) from the server"); } finally { Interlocked.Exchange(ref _loadingLocalModels, 0); } } /// /// Replaces with one row per server-reported /// model, enriched with catalog metadata (display name, params, size, /// brand logo); the vision flag comes from the server's /// architecture.input_modalities. Idempotent — clears before /// adding so repeated calls (e.g., on StateChanged) don't accumulate /// duplicates. Runs on the UI thread (callers await on it). /// private async Task PopulateLocalModelsAsync( IReadOnlyList serverModels) { var byRepo = await GetCatalogByRepoAsync(); LocalModels.Clear(); _localByServerId.Clear(); foreach (var sm in serverModels) { var item = BuildLocalItem(sm, byRepo); _localByServerId[sm.Id] = item; LocalModels.Add(item); } UpdateEmptyState(); } /// /// Returns the cached catalog reshaped into a bare-repo-id → /// lookup, collapsing the per-quant duplicates /// (a repo can appear several times in the flattened catalog). The /// projection is materialized once by a continuation over /// in ; callers just /// await the shared so a high-frequency /// caller (the 1s /models poller via ) /// doesn’t re-GroupBy the catalog on every tick. /// private Task> GetCatalogByRepoAsync() => _catalogByRepoTask; /// /// Builds an enriched for a server-reported /// model (display name, params, size, brand/logo from the catalog; vision /// and load state from the server snapshot). Seeds /// so an already-loaded model lands straight on the OpenInNewWindow glyph. /// private static ModelItem BuildLocalItem( LlamaManager.ServerModel sm, Dictionary byRepo) { var (repo, quant) = SplitServerId(sm.Id); byRepo.TryGetValue(repo, out var matched); return new ModelItem { Name = DeriveDisplayName(repo, quant, byRepo), RepoName = repo, Quant = quant, Description = matched?.Description ?? "", Parameters = matched?.Parameters ?? "", Size = matched?.Size ?? "", License = matched?.License ?? "", Vision = sm.SupportsImage, // authoritative — from the server Downloadable = false, Brand = matched?.Brand, Logo = ModelItem.ResolveLogo(matched?.Brand), IsLoaded = sm.IsLoaded, IsDownloading = sm.IsDownloading, }; } /// /// Splits a server model id (repo or repo:quant) into the /// bare HF repo id and the quant label (empty when absent). /// private static (string repo, string quant) SplitServerId(string id) { var idx = id.IndexOf(':'); return idx < 0 ? (id, "") : (id[..idx], id[(idx + 1)..]); } /// /// Builds a display name for a server-reported model: the catalog's /// DisplayName with the quant in parens when known, else the last /// path segment of the repo id (with quant in parens). /// private static string DeriveDisplayName( string repo, string quant, Dictionary byRepo) { byRepo.TryGetValue(repo, out var matched); var baseName = !string.IsNullOrEmpty(matched?.DisplayName) ? matched.DisplayName : repo.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? repo; return string.IsNullOrEmpty(quant) ? baseName : $"{baseName} ({quant})"; } /// /// Fetches the remote catalog and populates . /// Shares the single catalog fetch with the Available section. /// private async Task LoadRecommendedModelsAsync() { List repos; try { repos = await _catalogTask; } catch (Exception ex) { Log.Warn(ex, "recommended models load failed"); repos = []; } // An empty catalog after the fetch means it couldn't be loaded // (network/parse failure) — say so and offer a retry instead of // leaving the section silently blank. if (repos.Count == 0) { RecommendedScroll.Visibility = Visibility.Collapsed; RecommendedStatusPanel.Visibility = Visibility.Visible; RecommendedStatusText.Text = "Couldn't load the model catalog. Check your connection and try again."; RetryCatalogButton.Visibility = Visibility.Visible; return; } RecommendedModels.Clear(); // idempotent — safe on catalog retry // Build a display name that disambiguate quants: "GPT-OSS 20B (mxfp4)". // Featured families sort first (catalog order preserved otherwise). foreach (var repo in RecommendedOrdering.OrderForDisplay(repos)) { var label = !string.IsNullOrEmpty(repo.DisplayName) ? !string.IsNullOrEmpty(repo.Quant) ? $"{repo.DisplayName} ({repo.Quant})" : repo.DisplayName : repo.Name; RecommendedModels.Add(new ModelItem { Name = label, RepoName = repo.Name, Description = repo.Description, Parameters = repo.Parameters, Size = repo.Size, SizeBytes = repo.SizeBytes, License = repo.License, Vision = repo.Vision, Quant = repo.Quant, Downloadable = true, Brand = repo.Brand, Logo = ModelItem.ResolveLogo(repo.Brand), }); } RecommendedStatusPanel.Visibility = Visibility.Collapsed; RetryCatalogButton.Visibility = Visibility.Collapsed; RecommendedScroll.Visibility = Visibility.Visible; } /// /// Retry button shown when the catalog fetch fails: refetches the catalog /// and repopulates the Recommended section. The Available list is left /// alone — it comes from the running server, not the catalog. /// private void RetryCatalog_Click(object sender, RoutedEventArgs e) { RecommendedStatusText.Text = "Loading models…"; RetryCatalogButton.Visibility = Visibility.Collapsed; StartCatalogFetch(); _ = LoadRecommendedModelsAsync(); } /// /// Shows/hides the "No model yet." placeholder based on whether any /// local models are present. /// private void UpdateEmptyState() { var empty = LocalModels.Count == 0; NoLocalModelsText.Visibility = empty ? Visibility.Visible : Visibility.Collapsed; LocalModelsList.Visibility = empty ? Visibility.Collapsed : Visibility.Visible; if (empty) { // Honest per-state text (mapping rules live in // EmptyStatePresentation so they stay unit-testable): while the // server is still coming up the list may fill shortly — say so; // once it's running, point at the Recommended section; on a // crash / failed install, say that instead of claiming the // server is still starting forever. NoLocalModelsText.Text = EmptyStatePresentation.Describe( LlamaManager.Shared.ServerStatus, LlamaManager.Shared.State); } } // ---- Model download + launch ---- /// /// Fired when a row in the Recommended Models section is invoked — /// clicked, or Enter/Space on the focused row (the row root is a /// chromeless Button, so keyboard and screen-reader invokes land here /// too). Moves the model to the Available section with a progress ring, /// kicks off via the /// running llama server, then loads it (see /// ) when the download completes — the /// row transitions download ring -> load ring -> OpenInNewWindow glyph. /// private void RecommendedModel_Click(object sender, RoutedEventArgs e) { if (sender is not FrameworkElement fe) return; // x:Bind doesn't set DataContext inside a DataTemplate (compiled // bindings bypass the property), so read the row's model from its // Tag (bound via Tag="{x:Bind}") and fall back to a visual-tree // walk — same approach as the Available-row play/open buttons. if (ResolveRowItem(fe) is not { } item) return; if (item.IsDownloading) return; // already in flight (double-tap guard) // Disk-space preflight: a one-tap row starts a multi-GB download, // so block it up front when the cache drive can't hold the model // (an unknown size or a failed probe never blocks). if (item.SizeBytes > 0 && !Common.DiskSpace.HasEnoughSpace( Settings.Current.CacheDirectory, item.SizeBytes, out var freeBytes)) { Log.Info($"download blocked: {((IModel)item).ServerModelId} needs " + $"{item.SizeBytes} bytes, only {freeBytes} free"); ShowInsufficientSpaceFlyout(fe, item, freeBytes); return; } // Move the model from Recommended → Available (downloading). RecommendedModels.Remove(item); item.Downloadable = false; item.IsDownloading = true; _localByServerId[((IModel)item).ServerModelId] = item; LocalModels.Add(item); UpdateEmptyState(); _ = DownloadAndLaunchAsync(item); } /// /// Shows a light-dismiss flyout on the tapped Recommended row when the /// disk-space preflight blocks a download. A flyout (not a dialog) so /// the tray window's hide-on-deactivate can't strand a modal. /// private static void ShowInsufficientSpaceFlyout(FrameworkElement target, ModelItem item, long freeBytes) { // Free space is realistically GB-scale; drop to MB below that so a // nearly-full drive doesn't read "0 GB". var freeText = freeBytes >= 1_000_000_000 ? $"{freeBytes / 1_000_000_000.0:0.#} GB" : $"{Math.Max(0, freeBytes) / 1_000_000.0:0} MB"; var flyout = new Flyout { Content = new StackPanel { Spacing = 6, MaxWidth = 260, Children = { new TextBlock { Text = "Not enough disk space", FontSize = 13, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, }, new TextBlock { Text = $"{item.DisplayName} needs {item.Size}, but only " + $"{freeText} is free. Free up space, or change the " + "cache folder in Settings.", FontSize = 12, Opacity = 0.7, TextWrapping = TextWrapping.Wrap, }, }, }, }; flyout.ShowAt(target); } /// /// Drives a single model's download → load lifecycle. Reports /// progress to the property /// (bound to the download progress ring), then on success flips the row /// into the loading state, and asks the server to load it (see /// ). /// private async Task DownloadAndLaunchAsync(ModelItem item) { var mgr = LlamaManager.Shared; var queue = DispatcherQueue; // marshal progress back to the UI thread // Per-download cancellation: the row's cancel button cancels this // source. DownloadModelAsync closes the SSE stream and asks the // server to abort the download when the token fires. using var cts = new CancellationTokenSource(); item.DownloadCancellation = cts; // Reset any stale detail from a previous (failed) attempt — the // subtitle shows the live detail line as soon as a size is known. item.DownloadedBytes = 0; item.DownloadTotalBytes = 0; item.DownloadBytesPerSecond = 0; // Throttle UI updates: the server streams an SSE progress event per // chunk (potentially hundreds/sec), and each one would otherwise // enqueue a UI-thread callback. The ring + percent caption only need // ~10 updates/sec. Terminal events (Done/Failed) always pass through // so the final state lands immediately. long lastProgressApplyMs = 0; long lastSampleBytes = 0, lastSampleMs = 0; double bytesPerSecond = 0; string? serverMessage = null; var progress = new Progress(p => { var now = Environment.TickCount64; if (!p.Done && !p.Failed && now - lastProgressApplyMs < 100) return; lastProgressApplyMs = now; // The server's rejection detail (POST error body, stream // failure) — surfaced in the failure toast. if (p.Failed && !string.IsNullOrWhiteSpace(p.Message)) serverMessage = p.Message; // Speed estimate between applied samples (EMA-smoothed — the // per-chunk instantaneous rate jitters too much to show raw). if (p.DownloadedBytes > 0) { if (lastSampleMs != 0 && p.DownloadedBytes > lastSampleBytes) { var instantaneous = (p.DownloadedBytes - lastSampleBytes) * 1000.0 / Math.Max(1, now - lastSampleMs); bytesPerSecond = DownloadProgressPresentation .SmoothSpeed(bytesPerSecond, instantaneous); } lastSampleBytes = p.DownloadedBytes; lastSampleMs = now; } void Apply() { if (p.TotalBytes > 0) { item.DownloadFraction = p.Fraction; item.DownloadedBytes = p.DownloadedBytes; item.DownloadTotalBytes = p.TotalBytes; item.DownloadBytesPerSecond = bytesPerSecond; } } if (queue is null || queue.HasThreadAccess) Apply(); else queue.TryEnqueue(Apply); }); try { var ok = await mgr.DownloadModelAsync(item, progress, cts.Token); void Complete() { item.IsDownloading = false; if (ok) { // Download done — load it. The row now shows the // load ring until the poller reports the model as // loaded. item.LoadFailed = false; item.IsLoading = true; _ = LoadAndWatchAsync(item); } else { item.DownloadFailed = true; NotifyWhenHidden("Download failed", DownloadFailureToastBody(item, serverMessage)); } } if (queue is null || queue.HasThreadAccess) Complete(); else queue.TryEnqueue(Complete); } catch (OperationCanceledException) { // User canceled from the row's cancel button — the server has // already been asked to abort (see DownloadModelAsync). Return // the row to the play glyph; a partial download resumes on the // next attempt. if (queue is null || queue.HasThreadAccess) item.IsDownloading = false; else queue.TryEnqueue(() => item.IsDownloading = false); } catch { void Fail() { item.IsDownloading = false; item.DownloadFailed = true; NotifyWhenHidden("Download failed", DownloadFailureToastBody(item, serverMessage)); } if (queue is null || queue.HasThreadAccess) Fail(); else queue.TryEnqueue(Fail); } finally { // Clear before the `using` disposes so a late cancel click can // never touch a disposed source. item.DownloadCancellation = null; } } /// /// Builds the download-failure toast body, appending the server's /// rejection detail when one was reported (truncated so a JSON error /// body doesn't flood the toast). /// private static string DownloadFailureToastBody(ModelItem item, string? serverMessage) { var detail = ""; if (!string.IsNullOrWhiteSpace(serverMessage)) { var trimmed = serverMessage.Length > 140 ? serverMessage[..140] + "…" : serverMessage; detail = $" Server said: {trimmed}."; } return $"{item.DisplayName} couldn't be downloaded.{detail} Click retry to try again."; } // ---- Model load → open ---- /// /// Fired when the play glyph on an Available (local) row is tapped. /// Asks the running llama server to load the model and flips the row into /// the loading state (indeterminate ring) until the poller reports it as /// loaded. No-op if the row is already loading/loaded/downloading. /// private void LocalModelPlay_Click(object sender, RoutedEventArgs e) { if (sender is not FrameworkElement fe) { Log.Warn("sender is not a FrameworkElement"); return; } // x:Bind doesn't set DataContext on child elements inside a // DataTemplate (compiled bindings bypass the property), so read // the row's model from the bound Tag instead and fall back to a // visual-tree walk. if (ResolveRowItem(fe) is not { } item) { Log.Warn("could not resolve a ModelItem (Tag=" + (fe.Tag?.GetType().FullName ?? "null") + ")"); return; } if (item.IsLoading || item.IsLoaded || item.IsDownloading) { Log.Debug("ignored (isLoading=" + item.IsLoading + " isLoaded=" + item.IsLoaded + " isDownloading=" + item.IsDownloading + ")"); return; } Log.Info("play clicked: loading " + ((IModel)item).ServerModelId); item.LoadFailed = false; item.IsLoading = true; _ = LoadAndWatchAsync(item); } /// /// Fired when the retry glyph on a load-failed row is tapped: clears the /// failure state and re-attempts the load. (A load-failed row shows /// warning + retry instead of the play glyph so a rejected load — OOM, /// corrupt GGUF, server refusal — isn't silent.) /// private void LocalModelRetryLoad_Click(object sender, RoutedEventArgs e) { if (sender is not FrameworkElement fe || ResolveRowItem(fe) is not { } item) { Log.Warn("could not resolve a ModelItem"); return; } if (item.IsLoading || item.IsLoaded || item.IsDownloading || !item.LoadFailed) return; Log.Info("retry clicked: re-loading " + ((IModel)item).ServerModelId); item.LoadFailed = false; item.IsLoading = true; _ = LoadAndWatchAsync(item); } /// /// Fired when the cancel glyph next to the download ring is tapped: /// cancels the in-flight download. The server is asked to abort too /// (see ); the row returns /// to the play glyph and a partial download resumes on the next attempt. /// private void LocalModelCancelDownload_Click(object sender, RoutedEventArgs e) { if (sender is not FrameworkElement fe || ResolveRowItem(fe) is not { } item) { Log.Warn("could not resolve a ModelItem"); return; } Log.Info("cancel clicked: cancelling download of " + ((IModel)item).ServerModelId); try { item.DownloadCancellation?.Cancel(); } catch (ObjectDisposedException) { /* download finished between check and click */ } } /// /// Fired when the retry glyph on a failed-download row is tapped: clears /// the failure state and restarts the download → load lifecycle. (A /// failed row shows warning + retry instead of the play glyph — the /// model isn't fully cached, so loading it would just be rejected.) /// private void LocalModelRetryDownload_Click(object sender, RoutedEventArgs e) { if (sender is not FrameworkElement fe || ResolveRowItem(fe) is not { } item) { Log.Warn("could not resolve a ModelItem"); return; } if (item.IsDownloading || !item.DownloadFailed) return; Log.Info("retry clicked: re-downloading " + ((IModel)item).ServerModelId); item.DownloadFailed = false; item.IsDownloading = true; _ = DownloadAndLaunchAsync(item); } /// /// Fired when the trash glyph on an Available row is tapped. The button's /// attached confirmation Flyout opens automatically on the click; this /// just captures the row's model and the flyout so /// can act on them. Deleting /// means re-downloading GBs, so it never happens on a single misclick. /// private void LocalModelDelete_Click(object sender, RoutedEventArgs e) { if (sender is not Button btn) return; _pendingDelete = ResolveRowItem(btn); // Must read Button.Flyout, not FlyoutBase.GetAttachedFlyout: the // flyout is set via the property element, which is // Button's own property — GetAttachedFlyout reads the separate // FlyoutBase.AttachedFlyout attached property and returns null // here, which made the confirm handler's Hide() a silent no-op // (the flyout stayed open after clicking Delete). _deleteConfirmFlyout = btn.Flyout; } /// /// The Delete button inside the trash glyph's confirmation flyout: /// deletes the model from the running llama server's cache — sends /// DELETE /models/{name}; on success the row is removed from /// immediately (the poller's next tick would /// drop it too, but removing now avoids a stale row lingering for up to /// one poll interval). No-op if the row is loaded or loading — a /// resident model must be unloaded first. /// private async void LocalModelDeleteConfirm_Click(object sender, RoutedEventArgs e) { _deleteConfirmFlyout?.Hide(); if (_pendingDelete is not { } item) return; _pendingDelete = null; if (item.IsLoaded || item.IsLoading || item.IsDownloading) { Log.Debug("ignored delete (isLoading=" + item.IsLoading + " isLoaded=" + item.IsLoaded + " isDownloading=" + item.IsDownloading + ")"); return; } Log.Info("delete confirmed: removing " + ((IModel)item).ServerModelId); if (await LlamaManager.Shared.DeleteModelAsync(item)) { _localByServerId.Remove(((IModel)item).ServerModelId); LocalModels.Remove(item); UpdateEmptyState(); } else { Log.Warn("server rejected delete for " + ((IModel)item).ServerModelId); } } /// /// Opens the running llama server's WebUI in the system browser for the /// selected model — the action behind the OpenInNewWindow glyph on a /// loaded Available row. Passes ?model=<ServerModelId> so the /// server loads the requested model automatically. /// private async void LocalModelOpen_Click(object sender, RoutedEventArgs e) { if (sender is not FrameworkElement fe || ResolveRowItem(fe) is not { } item) { Log.Warn("could not resolve a ModelItem"); return; } var serverModelId = ((IModel)item).ServerModelId; Log.Info("open clicked for " + serverModelId); var url = $"http://localhost:{LlamaManager.Shared.ServerPort}?model={Uri.EscapeDataString(serverModelId)}"; await Windows.System.Launcher.LaunchUriAsync(new Uri(url)); } /// /// Unloads a loaded model from the running llama server — the action behind /// the power glyph next to the OpenInNewWindow glyph on a loaded Available /// row. Sends POST /models/unload and clears the row's loaded state /// once the server accepts the request; the poller will confirm the status /// change on its next tick. /// private async void LocalModelUnload_Click(object sender, RoutedEventArgs e) { if (sender is not FrameworkElement fe || ResolveRowItem(fe) is not { } item) { Log.Warn("could not resolve a ModelItem"); return; } if (!item.IsLoaded) { Log.Debug("ignored (not loaded)"); return; } Log.Info("unload clicked: unloading " + ((IModel)item).ServerModelId); if (await LlamaManager.Shared.UnloadModelAsync(item)) { item.IsLoaded = false; item.IsLoading = false; } else { Log.Warn("server rejected unload for " + ((IModel)item).ServerModelId); } } // ---- Toast notifications ---- /// /// Shows a toast for a background event the user is likely waiting on, /// but only when the flyout is hidden — when they're watching the panel, /// the row state already tells the story and a toast would be noise. /// private void NotifyWhenHidden(string title, string body) { if (!IsFlyoutVisible) Notifications.Show(title, body); } // ---- Row hover feedback ---- /// /// Re-resolves every row's brand logo after a theme change (the logo /// variant is theme-dependent and the cache has just been cleared). /// private void RefreshRowLogos() { foreach (var item in LocalModels.Concat(RecommendedModels)) item.Logo = ModelItem.ResolveLogo(item.Brand); } /// /// Paints the hovered model row with the theme's subtle fill so rows read /// as interactive (the Recommended rows are fully tappable; the Available /// rows host small icon buttons). The brush is resolved once from the app /// resources, which consult the active theme dictionary. /// private void Row_PointerEntered(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e) { if (sender is not Grid row) return; _rowHoverBrush ??= (Microsoft.UI.Xaml.Media.Brush)Application.Current .Resources["SubtleFillColorSecondaryBrush"]; row.Background = _rowHoverBrush; } /// /// Restores the row's resting background. Transparent, not null — a null /// Background makes the Grid invisible to hit-testing, so the next /// PointerEntered would never fire. /// private void Row_PointerExited(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e) { if (sender is Grid row) row.Background = RowRestBrush; } /// /// Resolves the a click came from. x:Bind /// doesn't propagate DataContext to child elements inside a /// DataTemplate (compiled bindings bypass the property), so the /// row's model is bound to the element's Tag via Tag="{x:Bind}"; /// this reads it. Falls back to a visual-tree walk (the ItemsRepeater /// sets DataContext on the row's root element) so it also works for /// elements that didn't bind Tag. /// private static ModelItem? ResolveRowItem(Microsoft.UI.Xaml.FrameworkElement fe) { if (fe.Tag is ModelItem tagItem) return tagItem; for (var el = fe; el is not null; el = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(el) as Microsoft.UI.Xaml.FrameworkElement) { if (el.DataContext is ModelItem dcItem) return dcItem; } return null; } /// /// Sends a POST /models/load for and, on /// rejection, clears the optimistic so /// the row falls back to the play glyph. While the load runs, the /// server's status_change SSE events drive the row's load ring /// via ; the /// poller owns the final /// transition (setting and clearing /// via ). /// private async Task LoadAndWatchAsync(ModelItem item) { var mgr = LlamaManager.Shared; var queue = DispatcherQueue; item.LoadFraction = 0; // Progress captures the UI thread's SynchronizationContext at // construction, so reports land on the UI thread unaided. Throttle // to ~10 updates/sec like the download ring (the server can stream // a status_change per mmap chunk); the terminal 100% always lands. long lastApplyMs = 0; var progress = new Progress(f => { var now = Environment.TickCount64; if (f < 1.0 && now - lastApplyMs < 100) return; lastApplyMs = now; item.LoadFraction = f; }); bool ok; try { ok = await mgr.LoadModelAsync(item, progress); } catch (Exception ex) { // The server dying mid-load faults the SSE watch with an // IOException; LoadModelAsync already maps that to false, but // don't let anything else escape this fire-and-forget call // either — a stuck IsLoading would spin the row's ring forever. Log.Warn(ex, "load watch threw"); ok = false; } if (!ok) { void Rejected() { item.IsLoading = false; item.LoadFraction = 0; // Surface the failure: without this the ring just vanished // and the play glyph returned with no explanation (OOM, // corrupt GGUF, server refusal all looked identical). item.LoadFailed = true; NotifyWhenHidden("Couldn't load model", $"{item.DisplayName} couldn't be loaded. It may not fit in memory, or the file may be corrupt."); } if (queue is null || queue.HasThreadAccess) Rejected(); else queue.TryEnqueue(Rejected); return; } // Accepted. The poller will flip IsLoaded=true / IsLoading=false once // the server reports the model resident. Watchdog: if the server never // reports loaded within a generous window (a large model can take a // while to mmap), give up on the spinner so the row falls back to the // play glyph and stays retryable rather than spinning forever. _ = Task.Run(async () => { await Task.Delay(TimeSpan.FromMinutes(2)); if (item.IsLoading && !item.IsLoaded) { void GiveUp() { if (!item.IsLoading || item.IsLoaded) return; item.IsLoading = false; // Same contract as a rejection: say we gave up rather // than silently dropping the spinner. item.LoadFailed = true; NotifyWhenHidden("Load timed out", $"{item.DisplayName} didn't finish loading within 2 minutes."); } if (queue is null || queue.HasThreadAccess) GiveUp(); else queue.TryEnqueue(GiveUp); } }); } // ---- Version footer ---- /// /// Fills the footer version line: the app's assembly version (no name) /// and the resolved llama.cpp version, separated by " - ". Only the /// version strings are shown, centered and bold white. /// private void LoadVersionInfo() { var appVer = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? ""; VersionText.Text = LlamaRunner.Version is { } v ? $"{appVer} - {v}" : appVer; } /// /// Re-renders the footer's server-status dot and relaunch button from /// (mapping rules live in /// so they stay unit-testable). /// Called on every and once at /// startup. /// private void UpdateServerStatusUI() { var d = ServerStatusPresentation.Describe( LlamaManager.Shared.ServerStatus, LlamaManager.Shared.State, LlamaManager.Shared.FailureMessage); ServerStatusDot.Fill = new Microsoft.UI.Xaml.Media.SolidColorBrush(d.Dot); Microsoft.UI.Xaml.Controls.ToolTipService.SetToolTip(ServerStatusDot, d.ToolTip); ServerRestartButton.Visibility = d.CanRelaunch ? Microsoft.UI.Xaml.Visibility.Visible : Microsoft.UI.Xaml.Visibility.Collapsed; } /// /// Relaunch button (footer, visible only when the server is down): /// re-runs the full ensure pipeline — adopt a server if one reappeared, /// otherwise resolve/install the binary and launch it. Single-flighted /// inside , so a /// double-click can't spawn two servers. /// private void ServerRestart_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { Log.Info("manual server relaunch requested"); _ = RelaunchServerAsync(); static async Task RelaunchServerAsync() { try { await LlamaManager.Shared.EnsureLlamaOrDownloadAsync(); } catch (Exception ex) { // Fire-and-forget from the button; the pipeline already // surfaces failures via the Failed state + red dot. Log.Error(ex, "manual server relaunch threw"); } } } /// /// Handler for : re-renders the /// footer's llama.cpp half as the binary is detected/installed so the /// running llama.cpp version appears live. /// private void LlamaManager_StateChanged(object? sender, EventArgs e) { // StateChanged can fire off the UI thread (the server process Exited // handler runs on a thread-pool thread), so marshal before touching // any UI element / the LocalModels collection. var dq = DispatcherQueue; if (dq is null || dq.HasThreadAccess) OnStateChanged(); else dq.TryEnqueue(OnStateChanged); void OnStateChanged() { LoadVersionInfo(); UpdateServerStatusUI(); // A crash used to surface only as the footer's 8px dot colour // — toast the reason (LlamaManager.FailureMessage) once per // transition into Failed so it isn't missed while hidden. var serverStatus = LlamaManager.Shared.ServerStatus; if (serverStatus == LlamaManager.ServerState.Failed && _lastServerStatus != LlamaManager.ServerState.Failed) { NotifyWhenHidden("Llama server stopped", LlamaManager.Shared.FailureMessage ?? "The llama server stopped responding."); } _lastServerStatus = serverStatus; // A dead server takes every in-flight operation with it — // downloads die mid-stream, loads never complete, loaded // models are gone (a restarted server comes back empty) — and // the poller that normally owns these flags stops while the // server is down. Reset all transient row state here so no row // keeps a ring (or a stale "open" glyph) until the server is // relaunched. App-driven downloads are left to their driver: // the dead SSE stream faults DownloadModelAsync and // DownloadAndLaunchAsync's catch-all flips the row to its // failed state with a toast. if (LlamaManager.Shared.ServerStatus != LlamaManager.ServerState.Running) { foreach (var row in LocalModels) { row.IsLoaded = false; row.IsLoading = false; row.LoadFraction = 0; if (row.DownloadCancellation is null) { row.IsDownloading = false; row.DownloadFraction = 0; } } // External-download watches die with the server too; // cancel them promptly rather than waiting for each dead // stream to fault on its own. foreach (var cts in _externalDownloadWatches.Values) cts.Cancel(); _externalDownloadWatches.Clear(); } // Keep the empty-state text in step with the server state // ("Starting the llama server…" → "No models yet — …"). UpdateEmptyState(); // (Re)populate the Available list once the server is actually // running — covers the startup race where the initial fetch ran // before the server was ready (or /models was momentarily empty). // Only triggers while the list is empty, so an in-flight download // row is never clobbered. if (LlamaManager.Shared.ServerStatus == LlamaManager.ServerState.Running && LocalModels.Count == 0) { _ = LoadLocalModelsAsync(); } } } /// /// Handler for (the 1s poller): /// marshals the fresh server snapshot to the UI thread and reconciles it /// into the Available rows in place. The poller fires on a background /// thread, so we never touch the ObservableCollection directly here. /// private void LlamaManager_ModelsChanged(object? sender, IReadOnlyList models) { var dq = DispatcherQueue; if (dq is null || dq.HasThreadAccess) _ = ReconcileAsync(models); else dq.TryEnqueue(() => _ = ReconcileAsync(models)); } /// /// Merges a fresh GET /models snapshot into /// without rebuilding the list (which would flicker and lose click/load /// state). Existing rows get their / /// flipped to match the server's /// reported status; models the server now knows about that we haven't /// listed yet (e.g. added to the cache out-of-band) are appended with /// catalog enrichment. Never clears rows — a transient empty/error /// snapshot is a no-op, so a network blip doesn't unload the list. /// private async Task ReconcileAsync(IReadOnlyList serverModels) { // If the initial populate hasn't run yet, let LoadLocalModelsAsync // build the list (and the index) once — reconcile only updates // existing rows. Avoid racing the first populate. if (LocalModels.Count == 0) { if (Interlocked.CompareExchange(ref _loadingLocalModels, 0, 0) == 0) _ = LoadLocalModelsAsync(); return; } var byRepo = await GetCatalogByRepoAsync(); foreach (var sm in serverModels) { if (!_localByServerId.TryGetValue(sm.Id, out var item) && (item = FindLocalByRepo(sm.Id)) is not null) { // Exact-match miss, but a row for the same repo exists: the // server ids a mid-download model by its bare repo (the quant // is resolved only once the download completes), while a row // moved from Recommended is keyed repo:catalogQuant. Adopt // the server's id — adding a row here would show the model // twice for the whole download (and leave a stale row after). AdoptServerId(item, sm.Id); } if (item is not null) { // Map the server's four model states onto the row: // loaded -> OpenInNewWindow glyph (IsLoaded, ring off) // loading -> load ring (server-truth load) // downloading-> download ring (server-truth download; stays // indeterminate for externally-triggered // downloads — no byte progress is tracked) // unloaded -> play glyph (but don't clobber an optimistic // IsLoading set by a just-fired play click that // the server hasn't acknowledged yet) if (sm.IsLoaded) { if (!item.IsLoaded) { Log.Info("model loaded: " + sm.Id); NotifyWhenHidden("Model ready", $"{item.DisplayName} is loaded and ready to chat."); } item.IsLoaded = true; item.IsLoading = false; item.IsDownloading = false; item.LoadFailed = false; // server-truth loaded clears any stale failure StopExternalDownloadWatch(item); } else if (sm.IsDownloading) { if (!item.IsDownloading) Log.Info("model downloading: " + sm.Id); item.IsLoaded = false; item.IsLoading = false; item.IsDownloading = true; // A download the app didn't start (WebUI/CLI) has no // driver wiring byte progress — watch it over SSE // ourselves, or the row sits on the indeterminate ring // for the whole download. if (item.DownloadCancellation is null) EnsureExternalDownloadWatch(item, sm.Id); } else if (sm.IsLoading) { if (!item.IsLoading) Log.Info("model loading: " + sm.Id); item.IsLoaded = false; item.IsLoading = true; item.IsDownloading = false; StopExternalDownloadWatch(item); } else // "unloaded" (or unknown) { if (item.IsLoaded) Log.Info("model unloaded: " + sm.Id); item.IsLoaded = false; // Clear IsDownloading only for downloads the poller owns // (externally triggered ones): an app-driven download's // driver (DownloadAndLaunchAsync) flips the row to loading // itself — clearing here first would bounce the row back // to the play glyph for up to one poll cycle. if (item.DownloadCancellation is null) { item.IsDownloading = false; StopExternalDownloadWatch(item); } // Leave IsLoading alone: a just-fired play click sets it // optimistically before the server transitions to "loading"; // clearing it here would flicker the ring off for up to one // poll cycle. Once the server reports "loading" or "loaded" // the branches above take over. } } else { // New server model not yet listed — add an enriched row. var newItem = BuildLocalItem(sm, byRepo); _localByServerId[sm.Id] = newItem; LocalModels.Add(newItem); Log.Info("added new local row from poller: " + sm.Id); } } // Sweep poller-owned download rows whose model vanished from // /models entirely: an externally canceled (or failed) download // disappears from the list, and without this the row would keep its // ring — and its progress watch — forever. App-driven downloads are // owned by their driver (DownloadAndLaunchAsync) and never touched. var serverIds = new HashSet( serverModels.Select(m => m.Id), StringComparer.OrdinalIgnoreCase); foreach (var (key, row) in _localByServerId) { if (row.IsDownloading && row.DownloadCancellation is null && !serverIds.Contains(key)) { Log.Info("download vanished from /models: " + key); StopExternalDownloadWatch(row); row.IsDownloading = false; row.DownloadFraction = 0; } } UpdateEmptyState(); } /// /// Starts (once per row) an SSE progress watch for a download the app did /// not start itself. The watch only feeds ; /// state transitions stay with the poller. It stops by itself when the /// download finishes or fails, and is canceled via /// when the row leaves the /// downloading state. /// private void EnsureExternalDownloadWatch(ModelItem item, string serverId) { if (_externalDownloadWatches.ContainsKey(item)) return; // Mid-download the server ids the model by its bare repo, which is // also what the SSE "model" field carries. var repo = SplitServerId(serverId).repo; var cts = new CancellationTokenSource(); _externalDownloadWatches[item] = cts; Log.Info("watching external download: " + repo); _ = WatchExternalDownloadAsync(item, repo, cts); } /// /// Cancels and forgets a row's external-download progress watch, if any. /// The token source is not disposed here — the watcher task disposes it /// itself when it unwinds, so it can never observe a disposed source. /// private void StopExternalDownloadWatch(ModelItem item) { if (_externalDownloadWatches.Remove(item, out var cts)) cts.Cancel(); } private async Task WatchExternalDownloadAsync(ModelItem item, string repo, CancellationTokenSource cts) { long lastApplyMs = 0; long lastSampleBytes = 0, lastSampleMs = 0; double bytesPerSecond = 0; var progress = new Progress(p => { // Same throttle as DownloadAndLaunchAsync: ~10 UI updates/s, and // total==0 events (stream noise) never touch the fraction. var now = Environment.TickCount64; if (!p.Done && now - lastApplyMs < 100) return; lastApplyMs = now; // Same speed estimate as the app-driven path, so an external // download's row shows the same detail line. if (p.DownloadedBytes > 0) { if (lastSampleMs != 0 && p.DownloadedBytes > lastSampleBytes) { var instantaneous = (p.DownloadedBytes - lastSampleBytes) * 1000.0 / Math.Max(1, now - lastSampleMs); bytesPerSecond = DownloadProgressPresentation .SmoothSpeed(bytesPerSecond, instantaneous); } lastSampleBytes = p.DownloadedBytes; lastSampleMs = now; } if (p.TotalBytes > 0) { item.DownloadFraction = p.Fraction; item.DownloadedBytes = p.DownloadedBytes; item.DownloadTotalBytes = p.TotalBytes; item.DownloadBytesPerSecond = bytesPerSecond; } }); try { await LlamaManager.Shared.WatchDownloadAsync(repo, progress, cts.Token); } catch (Exception ex) { // Fire-and-forget: nothing upstream would observe a fault. Log.Warn(ex, "external download watch faulted: " + repo); } // The entry may already be gone — or replaced by a newer watch — if // the poller stopped this one first; only remove our own. if (_externalDownloadWatches.TryGetValue(item, out var current) && ReferenceEquals(current, cts)) _externalDownloadWatches.Remove(item); cts.Dispose(); } /// /// Finds an Available row by bare repo id (the part of a server model id /// before :). The server ids a mid-download model by its bare repo /// — the quant is resolved only once the download completes — so an exact /// lookup misses rows that were keyed /// repo:quant (e.g. moved from Recommended on tap). /// private ModelItem? FindLocalByRepo(string serverId) { var (repo, _) = SplitServerId(serverId); foreach (var (key, row) in _localByServerId) { if (string.Equals(SplitServerId(key).repo, repo, StringComparison.OrdinalIgnoreCase)) return row; } return null; } /// /// Re-keys under the id the server is currently /// reporting for it, dropping any previous alias. Also adopts the server's /// resolved quant once the id carries one (mid-download ids are bare /// repos) — /models/load and DELETE /models/{name} must use /// the server's real id, which can differ from the catalog quant the row /// was tapped with. /// private void AdoptServerId(ModelItem item, string serverId) { foreach (var key in _localByServerId .Where(kv => ReferenceEquals(kv.Value, item)) .Select(kv => kv.Key).ToList()) _localByServerId.Remove(key); var (_, quant) = SplitServerId(serverId); if (quant.Length > 0 && !string.Equals(item.Quant, quant, StringComparison.OrdinalIgnoreCase)) { Log.Info($"adopting server-resolved quant {quant} for {serverId} (was {item.Quant})"); item.Quant = quant; } _localByServerId[serverId] = item; } // ---- Footer actions ---- private async void ServerLink_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { // Open the running llama server's WebUI in the system browser. await Windows.System.Launcher.LaunchUriAsync( new System.Uri($"http://localhost:{LlamaManager.Shared.ServerPort}")); } private void Settings_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { // Hide the flyout first so the settings dialog isn't drawn behind it // (the flyout would otherwise immediately deactivate and hide on its // own, but doing it explicitly avoids a flash). HideFlyout(); var w = new SettingsWindow(); // The token may have changed — re-resolve the header avatar. w.Closed += (_, _) => _ = LoadAvatarAsync(); w.Activate(); } // ---- HF avatar ---- // Profile URL the avatar button opens (hf.co/). Null while no // whoami-v2 lookup has succeeded. private string? _avatarProfileUrl; /// /// Resolves the Hugging Face user behind the configured token /// (whoami-v2) and shows their avatar in the header, left of the /// settings gear. Hidden when no token is configured; a rejected token /// or network failure just keeps the previous state — the avatar is a /// best-effort decoration. Runs on the UI thread after the await. /// private async Task LoadAvatarAsync() { try { var token = Settings.Current.HuggingFaceToken; if (string.IsNullOrWhiteSpace(token)) { AvatarButton.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; _avatarProfileUrl = null; return; } var info = await new HubClient(token).UserInfo.WhoAmI(); if (info is null) return; // rejected token / network hiccup — keep as-is if (!string.IsNullOrEmpty(info.AvatarUrl)) { AvatarPicture.ProfilePicture = new Microsoft.UI.Xaml.Media.Imaging .BitmapImage(new Uri(info.AvatarUrl)); } // Initials fallback if the image is missing or fails to load. AvatarPicture.DisplayName = info.Name; // The public profile page is hf.co/ — the whoami `id` // is an internal ObjectId the website doesn't route. _avatarProfileUrl = $"https://hf.co/{info.Name}"; AvatarButton.Visibility = Microsoft.UI.Xaml.Visibility.Visible; } catch (Exception ex) { Log.Warn(ex, "avatar load failed; staying hidden"); } } private async void Avatar_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { if (_avatarProfileUrl is null) return; await Windows.System.Launcher.LaunchUriAsync(new Uri(_avatarProfileUrl)); } private void Quit_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { ExitRequested?.Invoke(); } // ---- Flyout behavior ---- /// /// Configures the WinUI window as a borderless, non-resizable flyout with /// no taskbar/Alt-Tab entry. Realizing the HWND up front (via /// ) lets us position it before /// the first activation, so it never flashes at a default location. /// private void ConfigureAsFlyout() { if (_configured) return; _configured = true; var presenter = (OverlappedPresenter)AppWindow.Presenter; presenter.SetBorderAndTitleBar(false, false); // borderless, no title bar → rounded corners + shadow on Win11 presenter.IsResizable = false; presenter.IsMaximizable = false; presenter.IsMinimizable = false; AppWindow.IsShownInSwitchers = false; // remove from Alt-Tab / taskbar switcher // WS_EX_TOOLWINDOW keeps the window out of the taskbar entirely. _hwnd = WindowNative.GetWindowHandle(this); // Initial size. PositionNear re-sizes with the target monitor's DPI // on every show, so the window's current DPI is good enough here. var dpi = GetDpiForWindow(_hwnd); AppWindow.Resize(FlyoutSizeForDpi(dpi, dpi)); var ex = GetWindowLongCompat(_hwnd, GWL_EXSTYLE); SetWindowLongCompat(_hwnd, GWL_EXSTYLE, (IntPtr)(ex.ToInt32() | WS_EX_TOOLWINDOW)); // WinUI 3 windows are WS_OVERLAPPEDWINDOW by default, and that style // keeps a thin frame (the white 1px edge around the Mica surface) // even when HasBorder is false — SetBorderAndTitleBar(false,false) // only hides the title bar / resize border, not this frame. The fix // is to switch the window style to WS_POPUP (a frameless popup) and // re-apply it with SetWindowPos(SWP_FRAMECHANGED), the same approach // H.NotifyIcon's borderless tray flyout uses. The compositor still // draws the rounded corners + drop shadow on Win11. const int GWL_STYLE = -16; SetWindowLongCompat(_hwnd, GWL_STYLE, new IntPtr(0x80000000L)); const uint SWP_NOMOVE = 0x0002, SWP_NOSIZE = 0x0001, SWP_NOZORDER = 0x0004, SWP_NOOWNERZORDER = 0x0200, SWP_FRAMECHANGED = 0x0020; SetWindowPos(_hwnd, IntPtr.Zero, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED); // DWM non-client rendering off — belt-and-suspenders with the popup // style above so no DWM border is drawn either. var ncrp = DWMNCRP_DISABLED; DwmSetWindowAttribute(_hwnd, DWMWA_NCRENDERING_POLICY, ref ncrp, sizeof(int)); // Pin the corner radius to the standard 8px "round" style rather // than relying on the system default. WindowCorners.ApplyRound8(this); } /// /// Shows the flyout anchored near (the tray-icon /// click point, in physical screen coordinates). Pinned to the bottom-right /// of the nearest monitor's work area — just above the taskbar, next to /// the tray, where Windows 11 system-tray flyouts appear. /// public void ShowAsFlyout(Point anchor) { PositionNear(anchor); _lastShownMs = Environment.TickCount64; _allowHideOnDeactivate = false; // suppress deactivations during the show sequence if (!_activated) { _activated = true; // first-time activation shows the window at its set position } else { // Reshow: AppWindow.Show() alone is unreliable for a window that // was hidden while the process was in the background — Windows // may deny it foreground, so the previously-active window // snatches focus back and our Deactivated handler hides it again. // Mirror H.NotifyIcon's WindowExtensions.Show: drive both the // WinAppSDK and Win32 show state, then force foreground + activate. AppWindow.Show(); ShowWindow(_hwnd, SW_SHOW); SetForegroundWindow(_hwnd); } Activate(); // first-time activation shows the window at its set position } /// Hides the flyout without closing it. void HideFlyout() { AppWindow.Hide(); ShowWindow(_hwnd, SW_HIDE); } /// /// Esc dismisses the flyout — the same convention as the chat overlay /// (and as clicking away, which hides on deactivation). The accelerator /// is window-level, so it fires wherever focus sits inside the flyout. /// private void EscapeAccelerator_Invoked( Microsoft.UI.Xaml.Input.KeyboardAccelerator sender, Microsoft.UI.Xaml.Input.KeyboardAcceleratorInvokedEventArgs args) { args.Handled = true; HideFlyout(); } /// Whether the flyout is currently visible on screen. public bool IsFlyoutVisible => AppWindow.IsVisible; /// /// True when the flyout was hidden by a deactivation (i.e. the user /// clicked outside it, or clicked the tray icon) within the last grace /// period. Lets the tray left-click handler distinguish a click that /// *caused* the dismiss (don't reopen) from a fresh click a moment later /// (do open) — without this, clicking the icon to close would bounce the /// panel straight back open. /// public bool WasJustHiddenByDeactivate => _lastDeactivateHideMs != 0 && Environment.TickCount64 - _lastDeactivateHideMs < DeactivateHideGracePeriodMs; private void PositionNear(Point anchor) { // Pin the flyout to the bottom-right of the work area of the monitor // nearest the click — i.e. just above the taskbar, next to the tray. // The size is scaled by THAT monitor's DPI, so the flyout keeps the // same logical size whichever screen it appears on. var work = GetWorkArea(anchor); var (dpiX, dpiY) = GetMonitorDpi(anchor); var size = FlyoutSizeForDpi(dpiX, dpiY); AppWindow.Resize(size); AppWindow.Move(new PointInt32(work.Right - size.Width, work.Bottom - size.Height)); } private void MainWindow_Activated(object sender, WindowActivatedEventArgs args) { if (args.WindowActivationState == WindowActivationState.Deactivated) { // Clicking anywhere outside the flyout deactivates it — dismiss, // the same way Windows 11 system-tray flyouts behave. Two guards: // • _allowHideOnDeactivate suppresses a spurious deactivate // that can race ahead of the show sequence. // • The post-show grace swallows the focus-reclaim deactivation // that hits a reshow when foreground lock denies us foreground // (see ShowAsFlyout) — without it the reshow hides itself and // looks like it never reopened. if (!_allowHideOnDeactivate || Environment.TickCount64 - _lastShownMs <= ShownDeactivationGraceMs) return; _allowHideOnDeactivate = false; _lastDeactivateHideMs = Environment.TickCount64; HideFlyout(); } else { _allowHideOnDeactivate = true; } } private void MainWindow_Closed(object sender, WindowEventArgs args) { // The app lives in the tray: a "close" (e.g. Alt+F4) just hides the // flyout unless the tray manager is shutting us down (AllowClose). if (AllowClose) return; args.Handled = true; HideFlyout(); } // ---- Win32 interop: work-area lookup + extended window style ---- [StructLayout(LayoutKind.Sequential)] private struct RECT { public int Left, Top, Right, Bottom; } [StructLayout(LayoutKind.Sequential)] private struct MONITORINFO { public int cbSize; public RECT rcMonitor; public RECT rcWork; public uint dwFlags; } [StructLayout(LayoutKind.Sequential)] private struct POINT { public int X, Y; } [DllImport("user32.dll", ExactSpelling = true)] private static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags); [DllImport("user32.dll", ExactSpelling = true)] private static extern uint GetDpiForWindow(IntPtr hwnd); [DllImport("shcore.dll", ExactSpelling = true)] private static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY); private const int MDT_EFFECTIVE_DPI = 0; [DllImport("user32.dll", CharSet = CharSet.Auto)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO mi); [DllImport("user32.dll", EntryPoint = "GetWindowLong")] private static extern int GetWindowLong32(IntPtr hwnd, int nIndex); [DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")] private static extern IntPtr GetWindowLongPtr64(IntPtr hwnd, int nIndex); [DllImport("user32.dll", EntryPoint = "SetWindowLong")] private static extern int SetWindowLong32(IntPtr hwnd, int nIndex, int value); [DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")] private static extern IntPtr SetWindowLongPtr64(IntPtr hwnd, int nIndex, IntPtr value); [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); [DllImport("dwmapi.dll", PreserveSig = true)] private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize); private const int DWMWA_NCRENDERING_POLICY = 2; private const int DWMNCRP_DISABLED = 1; private static IntPtr GetWindowLongCompat(IntPtr hwnd, int nIndex) => IntPtr.Size == 4 ? (IntPtr)GetWindowLong32(hwnd, nIndex) : GetWindowLongPtr64(hwnd, nIndex); private static void SetWindowLongCompat(IntPtr hwnd, int nIndex, IntPtr value) { if (IntPtr.Size == 4) SetWindowLong32(hwnd, nIndex, value.ToInt32()); else SetWindowLongPtr64(hwnd, nIndex, value); } /// /// Returns the work area (excluding the taskbar) of the monitor nearest /// , in physical screen coordinates. /// private static RECT GetWorkArea(Point anchor) { var hmon = MonitorFromPoint(new POINT { X = anchor.X, Y = anchor.Y }, MONITOR_DEFAULTTONEAREST); var mi = new MONITORINFO { cbSize = Marshal.SizeOf() }; GetMonitorInfo(hmon, ref mi); return mi.rcWork; } /// /// Converts the flyout's DIP size to physical pixels at the given DPI. /// The flyout is designed in DIPs (the units XAML layout uses), but /// / take /// physical pixels — without this scaling the flyout's logical size /// (how much content fits) would shrink on high-DPI screens. /// private static SizeInt32 FlyoutSizeForDpi(uint dpiX, uint dpiY) => new( (int)Math.Round(FlyoutWidthDips * dpiX / 96.0), (int)Math.Round(FlyoutHeightDips * dpiY / 96.0)); /// /// Returns the effective DPI of the monitor nearest /// , defaulting to 96 (100% scaling) if the /// query fails. /// private static (uint X, uint Y) GetMonitorDpi(Point anchor) { var hmon = MonitorFromPoint(new POINT { X = anchor.X, Y = anchor.Y }, MONITOR_DEFAULTTONEAREST); return GetDpiForMonitor(hmon, MDT_EFFECTIVE_DPI, out var dpiX, out var dpiY) == 0 ? (dpiX, dpiY) : (96u, 96u); } } }