diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index c6e9b86..9209a06 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -11,7 +11,7 @@ body:
id: version
attributes:
label: Fleet version
- placeholder: 0.4.0 installer, portable ZIP, or a source checkout commit
+ placeholder: 0.4.1 installer, portable ZIP, or a source checkout commit
validations:
required: true
- type: input
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d1db8d2..48da758 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,16 @@
# changelog
+## 0.4.1 - 2026-10-26
+
+- changed **Dashboard** to switch pages inside the existing tray popup. removed
+ the separate desktop window and its taskbar entry.
+- kept Back, Refresh and Settings visible while the dashboard scrolls. page
+ navigation resets scroll; fresh data preserves the current dashboard position.
+- fitted the summary, charts and token tables to the popup's existing width,
+ using the same acrylic, typography and controls as the main usage page.
+- kept expanded local history, full reported limits, coverage notes and privacy
+ behavior from 0.4.0. stale launch requests no longer reopen the popup later.
+
## 0.4.0 - 2026-10-26
- added a **Dashboard** button to the native flyout and tray menu. it opens one
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c2e9d99..a731084 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -53,19 +53,19 @@ Rules for a new built-in:
## building a release locally
```powershell
-.\scripts\build-release.ps1 -Version 0.4.0
+.\scripts\build-release.ps1 -Version 0.4.1
```
This requires a .NET SDK as well as Python. It creates an isolated build
environment under the scratch directory, builds `dist\Fleet\Fleet.exe` and
-`FleetPanel.exe`, and writes `dist\Fleet-0.4.0-portable.zip`. The whole `Fleet`
+`FleetPanel.exe`, and writes `dist\Fleet-0.4.1-portable.zip`. The whole `Fleet`
directory is required. Use `-ScratchRoot` and `-OutputRoot` to choose physical
storage; `-BuildVenv` can select an existing build environment.
Building the installer additionally needs Inno Setup 6:
```powershell
-$env:FLEET_VERSION = "0.4.0"
+$env:FLEET_VERSION = "0.4.1"
$env:FLEET_APP_ICON = "\assets\fleet.ico"
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" installer\Fleet.iss
```
diff --git a/README.md b/README.md
index e017422..d3e87db 100644
--- a/README.md
+++ b/README.md
@@ -4,15 +4,15 @@
track Claude Code, OpenAI Codex, Grok CLI, Google Antigravity, and API providers
without opening each service. a tray meter tracks quota pressure; the native flyout
brings limits, reset times, pace, balances, and what your tokens would cost at API
-prices together. choose **Dashboard** for a larger, resizable window that stays
-open while you work. readings are marked **live, cached, or unavailable**, so an
+prices together. choose **Dashboard** to switch to a detailed page inside the
+same popup. readings are marked **live, cached, or unavailable**, so an
older result stays visibly older.
@@ -20,15 +20,15 @@ older result stays visibly older.
- v0.4.0 native flyout; sample data. readings depend on your connected accounts.
+ v0.4.1 native flyout; sample data. readings depend on your connected accounts.
## dashboard
click **Dashboard** at the top of the flyout, or right-click the tray icon and
-choose **Dashboard**. the native window shows all reported provider limits,
-reset times, API balances/spend, and local Claude Code/Codex usage in one place.
-resize it for one or two columns; **Refresh** and **Settings** stay at the top.
+choose **Dashboard**. the popup switches to the dashboard page, with all reported
+provider limits, reset times, API balances/spend, and local Claude Code/Codex usage.
+scroll for more detail; **Back**, **Refresh** and **Settings** stay at the top.
local history includes 24-hour and 14-day activity, token/cache breakdowns for
today, 7 and 30 days, and up to 20 models and projects from the last 7 days.
@@ -38,19 +38,19 @@ coverage notes flag compressed Codex archives that have not been indexed.
-
+
-*sample data rendered from the native dashboard; no personal account or project data.*
+*sample data rendered from the Dashboard page; no personal account or project data.*
## download
-| v0.4.0 · Python included | getting started |
+| v0.4.1 · Python included | getting started |
| --- | --- |
-| **[Windows installer](https://github.com/okisooo/fleet/releases/download/v0.4.0/Fleet-Setup-0.4.0.exe)** | per-user install; no administrator prompt; optional start at sign-in |
-| **[portable ZIP](https://github.com/okisooo/fleet/releases/download/v0.4.0/Fleet-0.4.0-portable.zip)** | extract and run `Fleet.exe` |
+| **[Windows installer](https://github.com/okisooo/fleet/releases/download/v0.4.1/Fleet-Setup-0.4.1.exe)** | per-user install; no administrator prompt; optional start at sign-in |
+| **[portable ZIP](https://github.com/okisooo/fleet/releases/download/v0.4.1/Fleet-0.4.1-portable.zip)** | extract and run `Fleet.exe` |
-[release notes and SHA-256 checksums](https://github.com/okisooo/fleet/releases/tag/v0.4.0)
+[release notes and SHA-256 checksums](https://github.com/okisooo/fleet/releases/tag/v0.4.1)
· [latest release](https://github.com/okisooo/fleet/releases/latest)
> [!WARNING]
diff --git a/docs/dashboard-dark.png b/docs/dashboard-dark.png
index 8e8bf9b..d60c386 100644
Binary files a/docs/dashboard-dark.png and b/docs/dashboard-dark.png differ
diff --git a/docs/dashboard.png b/docs/dashboard.png
index 3bb0578..0da93d7 100644
Binary files a/docs/dashboard.png and b/docs/dashboard.png differ
diff --git a/docs/providers.md b/docs/providers.md
index 2f23bcf..f11b144 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -13,7 +13,7 @@ response.
## quick start
-the native **Dashboard** button opens a larger view of the same provider readings.
+the native **Dashboard** button selects a detail page in the existing flyout.
it keeps cached and unavailable states visible and expands every reported limit,
including rows condensed in the tray flyout. local activity charts, model/project
breakdowns and token counts come only from Claude Code and Codex logs; they are
diff --git a/docs/screenshot-dark.png b/docs/screenshot-dark.png
index 0981d1b..e0ee884 100644
Binary files a/docs/screenshot-dark.png and b/docs/screenshot-dark.png differ
diff --git a/docs/screenshot.png b/docs/screenshot.png
index 94e6cc4..49cb524 100644
Binary files a/docs/screenshot.png and b/docs/screenshot.png differ
diff --git a/native/FleetPanel/DashboardView.cs b/native/FleetPanel/DashboardView.cs
index 27605eb..9a54ca6 100644
--- a/native/FleetPanel/DashboardView.cs
+++ b/native/FleetPanel/DashboardView.cs
@@ -9,65 +9,50 @@
namespace FleetPanel
{
- // A separate layout using the flyout's type, colours, buttons and meter helpers.
- // It never changes the flyout's fixed-width frame or screen-capture surface.
+ // The detailed page inside the same fixed-width tray flyout.
internal sealed partial class PanelView
{
- public FrameworkElement DashboardHeader(Action settings)
+ private FrameworkElement DashboardPage()
{
- var header = new DockPanel { Margin = new Thickness(28, 18, 28, 16), LastChildFill = true };
- var actions = new StackPanel { Orientation = Orientation.Horizontal };
- var refresh = Button("Refresh", "Refresh all provider readings", () => RefreshClicked?.Invoke());
- refresh.Margin = new Thickness(0, 0, 8, 0);
- actions.Children.Add(refresh);
- actions.Children.Add(Button("Settings", "Open Fleet settings", settings));
- DockPanel.SetDock(actions, Dock.Right);
- header.Children.Add(actions);
- header.Children.Add(Text("Fleet dashboard", 22, theme.Text, FontWeights.SemiBold));
- return header;
- }
-
- public FrameworkElement Dashboard(PanelData next, double width)
- {
- data = next ?? new PanelData();
- updated = data.Ts;
- tickers.Clear();
- var page = new StackPanel { Margin = new Thickness(28, 0, 28, 24) };
+ var body = new StackPanel { Margin = new Thickness(0, 4, 0, 4) };
var status = Text("", 12, theme.TextSecondary);
status.Margin = new Thickness(0, 0, 0, 12);
tickers.Add(() => status.Text = data.Live + " live · " + data.Cached + " cached · Updated " + Clock.Ago(data.Ts));
- page.Children.Add(status);
+ body.Children.Add(status);
var stale = DashNote("Readings are older than two minutes. Fleet may be offline or still collecting. Try Refresh.");
- tickers.Add(() => stale.Visibility = Clock.Now() - data.Ts > 120 ? Visibility.Visible : Visibility.Collapsed);
- page.Children.Add(stale);
+ tickers.Add(() => stale.Visibility = data.Ts > 0 && Clock.Now() - data.Ts > 120 ? Visibility.Visible : Visibility.Collapsed);
+ body.Children.Add(stale);
var history = data.History.Where(p => p.Spend != null).ToList();
if (!data.Cost || data.Summary?.State == "off")
- page.Children.Add(DashNote("Local usage estimates are turned off. Enable API-price estimates in Settings to see activity and token history."));
+ body.Children.Add(DashNote("Local usage estimates are turned off. Enable API-price estimates in Settings to see activity and token history."));
else if (data.Summary?.State == "indexing")
- page.Children.Add(DashNote("Reading local agent logs… Provider limits remain available while history is indexed."));
+ body.Children.Add(DashNote("Reading local agent logs… Provider limits remain available while history is indexed."));
else if (history.Count == 0)
- page.Children.Add(DashNote("No local token history yet. Activity and API-price estimates appear after Claude Code or Codex writes usage to its local logs."));
+ body.Children.Add(DashNote("No local token history yet. Activity and API-price estimates appear after Claude Code or Codex writes usage to its local logs."));
else
{
- page.Children.Add(DashTitle("API value from local logs"));
+ body.Children.Add(DashTitle("API value from local logs"));
var band = new Grid { Margin = new Thickness(0, 6, 0, 12) };
+ band.ColumnDefinitions.Add(new ColumnDefinition());
+ band.ColumnDefinitions.Add(new ColumnDefinition());
var totals = new[] { history.Sum(p => p.Spend.Today?.Cost ?? 0), history.Sum(p => p.Spend.Week?.Cost ?? 0), history.Sum(p => p.Spend.Month?.Cost ?? 0), history.Sum(p => p.Spend.Today?.Tokens ?? 0) };
var labels = new[] { "Today", "Last 7 days", "Last 30 days", "Tokens today" };
for (var i = 0; i < labels.Length; i++)
{
- band.ColumnDefinitions.Add(new ColumnDefinition());
- var cell = new StackPanel { Margin = new Thickness(0, 0, 16, 0) };
+ if (i % 2 == 0) band.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+ var cell = new StackPanel { Margin = new Thickness(0, 0, 12, 10) };
cell.Children.Add(Text(labels[i], 12, theme.TextSecondary));
- cell.Children.Add(Text(i == 3 ? Format.Tokens(totals[i]) : Format.Money(totals[i]), 28, theme.Text, FontWeights.SemiBold, tabular: true));
- Grid.SetColumn(cell, i);
+ cell.Children.Add(Text(i == 3 ? Format.Tokens(totals[i]) : Format.Money(totals[i]), 22, theme.Text, FontWeights.SemiBold, tabular: true));
+ Grid.SetColumn(cell, i % 2);
+ Grid.SetRow(cell, i / 2);
band.Children.Add(cell);
}
- page.Children.Add(band);
+ body.Children.Add(band);
var basis = Text("Claude Code and Codex tokens at API list prices. Subscriptions are not billed per token.", 12, theme.TextSecondary);
basis.TextWrapping = TextWrapping.Wrap;
basis.Margin = new Thickness(0, 0, 0, 20);
- page.Children.Add(basis);
+ body.Children.Add(basis);
var charts = new List();
var days = history.SelectMany(p => p.Spend.Daily).GroupBy(d => d.Label).OrderBy(g => g.Key)
.Select(g => new Day { Label = g.Key, Cost = g.Sum(d => d.Cost), Tokens = g.Sum(d => d.Tokens) }).ToList();
@@ -75,13 +60,13 @@ public FrameworkElement Dashboard(PanelData next, double width)
charts.Add(DashChart("Daily activity · last 14 days", days, days[0].Label, days[days.Count - 1].Label));
var hours = Enumerable.Range(0, 24).Select(i => new Day { Label = (24 - i) + " h ago", Cost = history.Sum(p => p.Spend.Hourly.ElementAtOrDefault(i)) }).ToList();
charts.Add(DashChart("Hourly activity · last 24 hours", hours, "24 h ago", "now"));
- page.Children.Add(DashColumns(charts, width));
+ body.Children.Add(DashStack(charts));
}
- page.Children.Add(DashTitle("Providers and limits"));
+ body.Children.Add(DashTitle("Providers and limits"));
if (data.Providers.Count == 0 && data.Inactive.Count == 0)
- page.Children.Add(DashNote("No providers connected yet. Sign in to a supported client or connect a Claude API key in Settings."));
- page.Children.Add(DashColumns(data.Providers.Select(DashProvider).ToList(), width));
+ body.Children.Add(DashNote("No providers connected yet. Sign in to a supported client or connect a Claude API key in Settings."));
+ body.Children.Add(DashStack(data.Providers.Select(DashProvider).ToList()));
foreach (var item in data.Inactive)
{
var row = new DockPanel { Margin = new Thickness(0, 8, 0, 8) };
@@ -93,37 +78,44 @@ public FrameworkElement Dashboard(PanelData next, double width)
}
var note = Text(item.Name + " · " + item.Label, 13, theme.TextSecondary);
note.TextWrapping = TextWrapping.Wrap; note.ToolTip = item.Tooltip;
- row.Children.Add(note); page.Children.Add(row);
+ row.Children.Add(note); body.Children.Add(row);
}
if (history.Count > 0)
{
- page.Children.Add(DashTitle("Local usage detail"));
+ body.Children.Add(DashTitle("Local usage detail"));
foreach (var provider in history)
{
var spend = provider.Spend;
var detail = new StackPanel();
- var table = DashTable(new[] { "Period", "API value", "Input", "Output", "Cache read", "Cache write" }, 1.4, 1, 1, 1, 1, 1);
+ var table = DashTable(new[] { "Period", "API value", "Tokens" }, 1.3, 1.2, 1);
foreach (var pair in new[] { ("Today", spend.Today), ("7 days", spend.Week), ("30 days", spend.Month) })
if (pair.Item2 != null)
{
var t = pair.Item2;
- DashRow(table, pair.Item1, Format.Money(t.Cost), Format.Tokens(t.Input), Format.Tokens(t.Output), Format.Tokens(t.CacheRead), Format.Tokens(t.CacheWrite));
+ DashRow(table, pair.Item1, Format.Money(t.Cost), Format.Tokens(t.Tokens));
}
detail.Children.Add(table);
+ var tokens = DashTable(new[] { "Token type", "Today", "7 d", "30 d" }, 1.5, 1, 1, 1);
+ Func, string[]> values = select => new[] { spend.Today, spend.Week, spend.Month }
+ .Select(t => t == null ? "—" : Format.Tokens(select(t))).ToArray();
+ foreach (var pair in new[] { ("Input", (Func)(t => t.Input)), ("Output", (Func)(t => t.Output)),
+ ("Cache read", (Func)(t => t.CacheRead)), ("Cache write", (Func)(t => t.CacheWrite)) })
+ DashRow(tokens, new[] { pair.Item1 }.Concat(values(pair.Item2)).ToArray());
+ detail.Children.Add(tokens);
if (spend.CostPerHour != null)
detail.Children.Add(Text("Recent pace: " + Format.Money(spend.CostPerHour.Value) + "/h · " + Format.Tokens(spend.TokensPerMin ?? 0) + " tokens/min", 12, theme.TextSecondary));
- page.Children.Add(DashCard(provider.Name + " · token breakdown", detail));
- page.Children.Add(DashColumns(new List {
+ body.Children.Add(DashCard(provider.Name + " · token breakdown", detail));
+ body.Children.Add(DashStack(new List {
DashShares(provider.Name + " · top models, last 7 days", spend.Models, true),
DashShares(provider.Name + " · top projects, last 7 days", spend.Projects, false)
- }, width));
+ }));
}
if (history.Any(p => p.Spend.Estimated))
- page.Children.Add(DashNote("Some models use an estimated family price. These figures can change when published prices change."));
+ body.Children.Add(DashNote("Some models use an estimated family price. These figures can change when published prices change."));
var unpriced = history.Sum(p => p.Spend.Unpriced);
if (unpriced > 0)
- page.Children.Add(DashNote(unpriced.ToString("N0", CultureInfo.InvariantCulture) + " messages have no known model price and are excluded from the API-value estimate."));
+ body.Children.Add(DashNote(unpriced.ToString("N0", CultureInfo.InvariantCulture) + " messages have no known model price and are excluded from the API-value estimate."));
}
if (data.Cost && data.LogFiles != null)
{
@@ -132,10 +124,10 @@ public FrameworkElement Dashboard(PanelData next, double width)
if (data.SkippedArchives > 0) coverage += " · " + data.SkippedArchives + " compressed Codex archives not indexed; totals are incomplete";
var note = Text(coverage, 12, data.SkippedArchives > 0 ? theme.CautionText : theme.TextTertiary);
note.TextWrapping = TextWrapping.Wrap; note.Margin = new Thickness(0, 16, 0, 0);
- page.Children.Add(note);
+ body.Children.Add(note);
}
Tick();
- return page;
+ return body;
}
private FrameworkElement DashProvider(Provider p)
@@ -212,44 +204,37 @@ private FrameworkElement DashChart(string title, IList days, string first,
var empty = Text("No priced activity in this period.", 13, theme.TextSecondary);
empty.Margin = new Thickness(0, 28, 0, 28); body.Children.Add(empty);
}
- else body.Children.Add(new DashboardChart(theme, days) { Height = 130, Margin = new Thickness(0, 12, 0, 6) });
+ else body.Children.Add(new DashboardChart(theme, days) { Height = 90, Margin = new Thickness(0, 10, 0, 6) });
var axis = new DockPanel();
var end = Text(last, 11, theme.TextTertiary); DockPanel.SetDock(end, Dock.Right); axis.Children.Add(end);
axis.Children.Add(Text(first, 11, theme.TextTertiary)); body.Children.Add(axis);
return DashCard(title, body);
}
- private Grid DashColumns(IList items, double width)
+ private StackPanel DashStack(IList items)
{
- var grid = new Grid();
- var columns = width >= 900 ? 2 : 1;
- for (var c = 0; c < columns; c++) grid.ColumnDefinitions.Add(new ColumnDefinition());
- for (var i = 0; i < items.Count; i++)
- {
- if (i % columns == 0) grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
- items[i].Margin = new Thickness(0, 0, columns == 2 && i % columns == 0 ? 16 : 0, 16);
- Grid.SetRow(items[i], i / columns); Grid.SetColumn(items[i], i % columns); grid.Children.Add(items[i]);
- }
- return grid;
+ var stack = new StackPanel();
+ foreach (var item in items) stack.Children.Add(item);
+ return stack;
}
private Border DashCard(string title, FrameworkElement child, Provider provider = null)
{
var body = new StackPanel();
- var heading = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 10) };
+ var heading = new DockPanel { Margin = new Thickness(0, 0, 0, 10) };
if (provider != null)
{
- var icon = Icon(provider.Icon, provider.Mark, 18); icon.Margin = new Thickness(0, 0, 8, 0); heading.Children.Add(icon);
+ var icon = Icon(provider.Icon, provider.Mark, 18); icon.Margin = new Thickness(0, 0, 8, 0); DockPanel.SetDock(icon, Dock.Left); heading.Children.Add(icon);
}
- heading.Children.Add(Text(title, 14, theme.Text, FontWeights.SemiBold));
+ var label = Text(title, 14, theme.Text, FontWeights.SemiBold); label.TextWrapping = TextWrapping.Wrap; heading.Children.Add(label);
body.Children.Add(heading); body.Children.Add(child);
- return new Border { Child = body, Padding = new Thickness(18), Margin = new Thickness(0, 0, 0, 16),
- Background = Brush(theme.ControlFill), BorderBrush = Brush(theme.Divider), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(6) };
+ return new Border { Child = body, Padding = new Thickness(0, 4, 0, 12), Margin = new Thickness(0, 0, 0, 12),
+ BorderBrush = Brush(theme.Divider), BorderThickness = new Thickness(0, 0, 0, 1) };
}
private FrameworkElement DashTitle(string title)
{
- var label = Text(title, 16, theme.Text, FontWeights.SemiBold); label.Margin = new Thickness(0, 8, 0, 12); return label;
+ var label = Text(title, 14, theme.Text, FontWeights.SemiBold); label.Margin = new Thickness(0, 8, 0, 10); return label;
}
private FrameworkElement DashNote(string text)
diff --git a/native/FleetPanel/DashboardWindow.cs b/native/FleetPanel/DashboardWindow.cs
deleted file mode 100644
index a3a1c22..0000000
--- a/native/FleetPanel/DashboardWindow.cs
+++ /dev/null
@@ -1,131 +0,0 @@
-using System;
-using System.IO;
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Interop;
-using System.Windows.Media;
-using System.Windows.Media.Imaging;
-using System.Windows.Threading;
-
-namespace FleetPanel
-{
- /// A single, lazy, resizable dashboard. It shares the tray's safe model;
- /// no credentials, network client or second collector live in this window.
- internal sealed class DashboardWindow : Window
- {
- private readonly DockPanel root = new DockPanel();
- private readonly ScrollViewer scroll = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled };
- private readonly DispatcherTimer clock = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
- private readonly DispatcherTimer resize = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(180) };
- private PanelView view;
- private PanelData data;
- private bool light;
- private int columns;
- public event Action RefreshRequested;
- public event Action SettingsRequested;
- public event Action ActionRequested;
-
- public DashboardWindow(PanelData data, bool light)
- {
- this.data = data; this.light = light;
- Title = "Fleet dashboard";
- MinWidth = Math.Min(680, Math.Max(320, SystemParameters.WorkArea.Width - 24));
- MinHeight = Math.Min(420, Math.Max(240, SystemParameters.WorkArea.Height - 24));
- Width = Math.Max(MinWidth, Math.Min(1120, SystemParameters.WorkArea.Width - 48));
- Height = Math.Max(MinHeight, Math.Min(850, SystemParameters.WorkArea.Height - 48));
- WindowStartupLocation = WindowStartupLocation.CenterScreen;
- ResizeMode = ResizeMode.CanResize;
- ShowInTaskbar = true;
- if (Environment.GetEnvironmentVariable("FLEET_PANEL_OFFSCREEN") == "1")
- {
- WindowStartupLocation = WindowStartupLocation.Manual; Left = Top = -32000;
- ShowActivated = false; ShowInTaskbar = false;
- }
- Content = root;
- TextOptions.SetTextFormattingMode(root, TextFormattingMode.Display);
- UseLayoutRounding = true;
- BuildTheme();
- SourceInitialized += (s, e) => { FrameTheme(); LoadIcon(); };
- clock.Tick += (s, e) => { if (IsVisible && WindowState != WindowState.Minimized) view.Tick(); };
- resize.Tick += (s, e) => { resize.Stop(); Rebuild(); };
- SizeChanged += (s, e) => { if ((ActualWidth >= 900 ? 2 : 1) != columns) { resize.Stop(); resize.Start(); } };
- Closed += (s, e) => { clock.Stop(); resize.Stop(); };
- clock.Start();
- }
-
- public void Update(PanelData next)
- {
- data = next;
- Rebuild();
- }
-
- public void ChangeTheme(bool next)
- {
- if (light == next) return;
- light = next;
- BuildTheme();
- FrameTheme();
- }
-
- private void FrameTheme()
- {
- var hwnd = new WindowInteropHelper(this).Handle;
- if (hwnd == IntPtr.Zero) return;
- var dark = light ? 0 : 1;
- Native.DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int));
- }
-
- private void LoadIcon()
- {
- var exe = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Fleet.exe");
- if (!File.Exists(exe)) return;
- try
- {
- using (var icon = System.Drawing.Icon.ExtractAssociatedIcon(exe))
- {
- if (icon == null) return;
- Icon = Imaging.CreateBitmapSourceFromHIcon(icon.Handle, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
- Icon.Freeze();
- }
- }
- catch (ArgumentException) { }
- catch (System.ComponentModel.Win32Exception) { }
- }
-
- private void BuildTheme()
- {
- var theme = Theme.Create(light);
- Background = new SolidColorBrush(theme.Fallback);
- view = new PanelView(theme);
- view.RefreshClicked += () => RefreshRequested?.Invoke();
- view.ActionClicked += action => ActionRequested?.Invoke(action);
- root.Children.Clear();
- var header = view.DashboardHeader(() => SettingsRequested?.Invoke());
- DockPanel.SetDock(header, Dock.Top); root.Children.Add(header);
- root.Children.Add(scroll);
- Rebuild();
- }
-
- private void Rebuild()
- {
- var offset = scroll.VerticalOffset;
- var width = ActualWidth > 0 ? ActualWidth : Width;
- columns = width >= 900 ? 2 : 1;
- scroll.Content = view.Dashboard(data, width);
- scroll.UpdateLayout();
- scroll.ScrollToVerticalOffset(offset);
- }
-
- /// Same layout for the sample-data renderer; no desktop window.
- public static FrameworkElement Preview(PanelData data, bool light, double width)
- {
- var view = new PanelView(Theme.Create(light));
- var root = new DockPanel { Background = new SolidColorBrush(Theme.Create(light).Fallback) };
- var header = view.DashboardHeader(() => { });
- DockPanel.SetDock(header, Dock.Top); root.Children.Add(header);
- root.Children.Add(new ScrollViewer { Content = view.Dashboard(data, width),
- VerticalScrollBarVisibility = ScrollBarVisibility.Auto, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled });
- return root;
- }
- }
-}
diff --git a/native/FleetPanel/FleetPanel.csproj b/native/FleetPanel/FleetPanel.csproj
index d80645e..e0e4d92 100644
--- a/native/FleetPanel/FleetPanel.csproj
+++ b/native/FleetPanel/FleetPanel.csproj
@@ -25,7 +25,6 @@
-
diff --git a/native/FleetPanel/PanelView.cs b/native/FleetPanel/PanelView.cs
index d1c9758..697be45 100644
--- a/native/FleetPanel/PanelView.cs
+++ b/native/FleetPanel/PanelView.cs
@@ -63,6 +63,7 @@ internal sealed partial class PanelView
private readonly Image backdrop = new Image { Stretch = Stretch.Fill };
private readonly GlassEffect glass;
private readonly StackPanel content = new StackPanel();
+ private readonly StackPanel navigation = new StackPanel();
private readonly ScrollViewer scroller;
private readonly TranslateTransform lift = new TranslateTransform();
private readonly List tickers = new List();
@@ -80,7 +81,6 @@ internal sealed partial class PanelView
private EventHandler counting;
public event Action RefreshClicked;
- public event Action DashboardRequested;
/// Raised once the open animation settles, with its measured frame pacing.
public event Action IntroMeasured;
/// A fix the user asked for: "claude-sign-in", "grok-renew".
@@ -158,7 +158,11 @@ public PanelView(Theme theme)
PanningMode = PanningMode.VerticalOnly,
Focusable = false,
};
- card.Children.Add(new Border { Padding = new Thickness(Pad, 8, Pad, 10), Child = scroller });
+ var pages = new DockPanel();
+ DockPanel.SetDock(navigation, Dock.Top);
+ pages.Children.Add(navigation);
+ pages.Children.Add(scroller);
+ card.Children.Add(new Border { Padding = new Thickness(Pad, 8, Pad, 10), Child = pages });
card.SizeChanged += (s, e) => Reframe();
Root.SizeChanged += (s, e) => Reframe();
@@ -234,7 +238,7 @@ public void Show(PanelData next, bool animate = false)
// A scheduled data push must not erase a key while the user types.
if (page == "settings" && apiEditing != "") return;
if (apiFeedback == "Key saved. Checking connection…") apiFeedback = "";
- Rebuild();
+ Rebuild(keepScroll: true);
}
public void ShowSettings()
@@ -243,6 +247,14 @@ public void ShowSettings()
Rebuild();
}
+ public void ShowDashboard()
+ {
+ ClearApiEditor();
+ page = "dashboard";
+ selected = "";
+ Rebuild();
+ }
+
public void ShowUsage()
{
ClearApiEditor();
@@ -252,6 +264,7 @@ public void ShowUsage()
selected = "";
Rebuild();
}
+ else scroller.ScrollToTop();
}
public void ClearApiEditor()
@@ -268,18 +281,31 @@ public void Select(string id)
Rebuild();
}
- private void Rebuild()
+ private void Rebuild(bool keepScroll = false)
{
+ var offset = keepScroll ? scroller.VerticalOffset : 0;
apiInput?.Clear();
apiInput = null;
content.Children.Clear();
+ navigation.Children.Clear();
tickers.Clear();
sections.Clear();
+ scroller.VerticalScrollBarVisibility = ScrollBarVisibility.Hidden;
+ if (page == "dashboard")
+ {
+ navigation.Children.Add(Header("Dashboard", back: true));
+ content.Children.Add(DashboardPage());
+ Tick();
+ scroller.UpdateLayout();
+ scroller.ScrollToVerticalOffset(offset);
+ return;
+ }
if (page == "settings")
{
content.Children.Add(Header("Settings", back: true));
content.Children.Add(SettingsPage());
Tick();
+ scroller.ScrollToVerticalOffset(offset);
return;
}
content.Children.Add(Header("Fleet", back: false));
@@ -309,6 +335,7 @@ private void Rebuild()
content.Children.Add(Divider());
content.Children.Add(Footer());
Tick();
+ scroller.ScrollToVerticalOffset(offset);
}
// ---------- motion ----------
@@ -432,12 +459,15 @@ private UIElement Header(string title, bool back)
}
left.Children.Add(Text(title, 14, theme.Text, FontWeights.SemiBold));
grid.Children.Add(left);
- if (!back)
+ if (!back || page == "dashboard")
{
var actions = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
- var dashboard = Button("Dashboard", "Open the full usage dashboard", () => DashboardRequested?.Invoke());
- dashboard.Margin = new Thickness(0, 0, 8, 0);
- actions.Children.Add(dashboard);
+ if (!back)
+ {
+ var dashboard = Button("Dashboard", "View more usage in this popup", ShowDashboard);
+ dashboard.Margin = new Thickness(0, 0, 8, 0);
+ actions.Children.Add(dashboard);
+ }
var refresh = IconButton("", "Refresh usage now", () => { Spin(); RefreshClicked?.Invoke(); });
((TextBlock)refresh.Child).RenderTransformOrigin = new Point(0.5, 0.5);
((TextBlock)refresh.Child).RenderTransform = spin;
@@ -1530,8 +1560,12 @@ private Border IconButton(string glyph, string name, Action click)
var button = new Border
{
Width = 32, Height = 32, CornerRadius = new CornerRadius(4), Background = Brushes.Transparent,
- Cursor = Cursors.Hand, Child = text, ToolTip = name,
+ Cursor = Cursors.Hand, Child = text, ToolTip = name, Focusable = true,
};
+ button.KeyDown += (s, e) => { if (e.Key == Key.Enter || e.Key == Key.Space) { click(); e.Handled = true; } };
+ button.GotKeyboardFocus += (s, e) => button.Background = Brush(theme.Hover);
+ button.LostKeyboardFocus += (s, e) => button.Background = Brushes.Transparent;
+ AutomationName(button, name);
ToolTipService.SetInitialShowDelay(button, 600);
button.MouseEnter += (s, e) => button.Background = Brush(theme.Hover);
button.MouseLeave += (s, e) => button.Background = Brushes.Transparent;
diff --git a/native/FleetPanel/PanelWindow.cs b/native/FleetPanel/PanelWindow.cs
index 5684391..ee2c9e0 100644
--- a/native/FleetPanel/PanelWindow.cs
+++ b/native/FleetPanel/PanelWindow.cs
@@ -46,8 +46,6 @@ internal sealed class PanelWindow : Window
// Click-away watching only runs while the flyout is open.
private readonly DispatcherTimer clickTimer = new DispatcherTimer(DispatcherPriority.Input) { Interval = TimeSpan.FromMilliseconds(16) };
private PanelView view;
- private DashboardWindow dashboard;
- private PanelData currentData = new PanelData();
private bool light;
private IntPtr hwnd;
private bool liveCapture;
@@ -138,12 +136,12 @@ private void BuildView()
{
view = new PanelView(Theme.Create(light));
view.RefreshClicked += RequestRefresh;
- view.DashboardRequested += OpenDashboard;
view.ActionClicked += action =>
{
if (action == "claude-sign-in") SignIn();
else if (action == "grok-renew") RenewGrok();
else if (action == "anthropic-billing") OpenBilling();
+ else if (action == "api-settings") { view.ShowSettings(); UpdateLayout(); }
};
view.SettingChanged += SaveSetting;
view.CredentialChanged += (id, key) =>
@@ -253,6 +251,7 @@ private IntPtr OnMessage(IntPtr window, int message, IntPtr wParam, IntPtr lPara
if (message == WM_FLEET_DASHBOARD)
{
handled = true;
+ anchor = Native.TrayIconRect(wParam) ?? anchor;
OpenDashboard();
return (IntPtr)1;
}
@@ -279,8 +278,9 @@ private void OnSignalTick()
}
if (File.Exists(PathOf("dashboard.show")))
{
+ var requested = Mtime("dashboard.show");
try { File.Delete(PathOf("dashboard.show")); } catch (IOException) { }
- OpenDashboard();
+ if ((DateTime.UtcNow - requested).TotalSeconds <= 10) OpenDashboard();
}
var seen = Mtime("panel.show");
if (seen > signalSeen && seen != DateTime.MinValue)
@@ -295,7 +295,7 @@ private void OnSignalTick()
{
WriteHeartbeat();
}
- if (((open && !closing) || (dashboard != null && dashboard.IsVisible && dashboard.WindowState != WindowState.Minimized)) && (now - lastVisibleTouch).TotalSeconds >= 2)
+ if (open && !closing && (now - lastVisibleTouch).TotalSeconds >= 2)
{
Touch("panel.visible");
lastVisibleTouch = now;
@@ -314,7 +314,6 @@ private void OnSignalTick()
{
light = !light;
BuildView();
- dashboard?.ChangeTheme(light);
}
}
}
@@ -355,9 +354,7 @@ private void LoadModel()
{
var data = PanelData.Load(PathOf("panel.json"));
modelSeen = stamp;
- currentData = data;
view.Show(data, animate: open);
- dashboard?.Update(data);
// Lay out now, while nobody is waiting, so an open never has to.
UpdateLayout();
if (!open)
@@ -453,30 +450,7 @@ private bool WriteAtomic(string name, string text)
private void OpenDashboard()
{
- LoadModel();
- if (dashboard == null)
- {
- dashboard = new DashboardWindow(currentData, light);
- dashboard.RefreshRequested += RequestRefresh;
- dashboard.SettingsRequested += DashboardSettings;
- dashboard.ActionRequested += action =>
- {
- if (action == "api-settings") DashboardSettings();
- else if (action == "claude-sign-in") SignIn();
- else if (action == "grok-renew") RenewGrok();
- else if (action == "anthropic-billing") OpenBilling();
- };
- dashboard.Closed += (s, e) => dashboard = null;
- }
- if (open) Close(animate: false);
- dashboard.Show();
- if (dashboard.WindowState == WindowState.Minimized) dashboard.WindowState = WindowState.Normal;
- if (Environment.GetEnvironmentVariable("FLEET_PANEL_OFFSCREEN") != "1") dashboard.Activate();
- }
-
- private void DashboardSettings()
- {
- view.ShowSettings();
+ view.ShowDashboard();
UpdateLayout();
if (!open || closing) Toggle();
}
@@ -640,12 +614,16 @@ private void Open()
view.Tick();
view.PrepareIntro();
Place();
- CaptureBackdrop();
+ var offscreen = Environment.GetEnvironmentVariable("FLEET_PANEL_OFFSCREEN") == "1";
+ if (!offscreen) CaptureBackdrop();
var placed = clock.Elapsed.TotalMilliseconds;
Native.SetWindowPos(hwnd, IntPtr.Zero, placedLeft, placedTop, 0, 0, Native.SWP_NOSIZE | Native.SWP_NOZORDER | Native.SWP_NOACTIVATE);
view.PlayIntro();
- Activate();
- Native.SetForegroundWindow(hwnd);
+ if (!offscreen)
+ {
+ Activate();
+ Native.SetForegroundWindow(hwnd);
+ }
var shown = clock.Elapsed.TotalMilliseconds;
EventHandler firstFrame = null;
firstFrame = (s, e) =>
@@ -656,7 +634,7 @@ private void Open()
};
CompositionTarget.Rendering += firstFrame;
for (var i = 0; i < buttons.Length; i++) buttons[i] = (Native.GetAsyncKeyState(i) & 0x8000) != 0;
- clickTimer.Start();
+ if (!offscreen) clickTimer.Start();
// Everything that can wait for the first frame does.
Dispatcher.BeginInvoke(new Action(() =>
diff --git a/native/FleetPanel/Program.cs b/native/FleetPanel/Program.cs
index 29f2e19..a2dd5e0 100644
--- a/native/FleetPanel/Program.cs
+++ b/native/FleetPanel/Program.cs
@@ -99,23 +99,11 @@ private static void WatchParent(Application app)
/// page: "usage", "settings", or "detail:<provider id>".
private static int Render(string model, string backdropPath, string output, bool light, double scale, string page)
{
- if (page == "dashboard" || page == "dashboard-narrow")
- {
- var width = page == "dashboard-narrow" ? 760.0 : 1120.0;
- var dashboardRoot = DashboardWindow.Preview(PanelData.Load(model), light, width);
- dashboardRoot.Measure(new Size(width, 900));
- dashboardRoot.Arrange(new Rect(0, 0, width, 900));
- dashboardRoot.UpdateLayout();
- var dashboardBitmap = new RenderTargetBitmap((int)(width * scale), (int)(900 * scale), 96 * scale, 96 * scale, PixelFormats.Pbgra32);
- dashboardBitmap.Render(dashboardRoot);
- var dashboardEncoder = new PngBitmapEncoder();
- dashboardEncoder.Frames.Add(BitmapFrame.Create(dashboardBitmap));
- using (var stream = File.Create(output)) dashboardEncoder.Save(stream);
- return 0;
- }
var view = new PanelView(Theme.Create(light));
view.Show(PanelData.Load(model));
if (page == "settings") view.ShowSettings();
+ else if (page == "dashboard") { view.ShowDashboard(); view.SetFrame(900, 900); }
+ else if (page == "dashboard-full") view.ShowDashboard();
else if (page.StartsWith("detail:")) view.Select(page.Substring("detail:".Length));
var root = view.Root;
// Twice: the first pass sizes the card, which then pins the backdrop to it.
diff --git a/scripts/check-dashboard.py b/scripts/check-dashboard.py
index 7a2de66..c7d1741 100644
--- a/scripts/check-dashboard.py
+++ b/scripts/check-dashboard.py
@@ -16,12 +16,14 @@
import clr
for name in ('WindowsBase','PresentationCore','PresentationFramework'):
clr.AddReference(str(Path(r'C:\Windows\Microsoft.NET\Framework64\v4.0.30319\WPF')/(name+'.dll')))
-from System import Activator, Array, Object
+from System import Activator, Array, Boolean, Int32, IntPtr, Object
from System.Reflection import Assembly, BindingFlags
from System.Threading import ApartmentState, Thread, ThreadStart
-from System.Windows import Application, LogicalTreeHelper, PresentationSource, ShutdownMode
+from System.Windows import Application, LogicalTreeHelper, Point, PresentationSource, ShutdownMode
+from System.Windows.Controls import TextBlock
from System.Windows.Automation import AutomationProperties
from System.Windows.Input import Key, KeyEventArgs, Keyboard
+from System.Windows.Interop import WindowInteropHelper
from System.Windows.Threading import DispatcherFrame, DispatcherTimer
from System import TimeSpan, EventHandler
@@ -62,42 +64,58 @@ def verify():
parent=Activator.CreateInstance(assembly.GetType('FleetPanel.PanelWindow'),Array[Object]([str(scratch.resolve())]))
call(parent,'Start');pump(1200)
view=field(parent,'view');root=view.GetType().GetProperty('Root').GetValue(view)
+ handle=WindowInteropHelper(parent).Handle
press(root,'Dashboard');pump()
- dashboard=field(parent,'dashboard')
- assert dashboard is not None and dashboard.IsVisible
- assert dashboard.Left < -10000 and dashboard.Top < -10000
- checks.append('Enter on the flyout Dashboard button opens an offscreen native window')
- press(root,'Dashboard');pump()
- assert field(parent,'dashboard')==dashboard
- checks.append('repeated clicks reuse the same dashboard')
- dashboard.Width=760;pump(350)
- assert field(dashboard,'columns')==1
- dashboard.Width=1120;pump(350)
- assert field(dashboard,'columns')==2
- checks.append('resize switches between one and two columns')
- scroll=field(dashboard,'scroll')
- scroll.ScrollToVerticalOffset(260);dashboard.UpdateLayout();pump()
+ assert field(view,'page')=='dashboard'
+ assert app.Windows.Count==1 and assembly.GetType('FleetPanel.DashboardWindow') is None
+ assert parent.Left < -10000 and parent.Top < -10000
+ assert view.GetType().GetProperty('Root').GetValue(view)==root
+ assert WindowInteropHelper(parent).Handle==handle and not parent.ShowInTaskbar
+ checks.append('Dashboard changes the existing popup page; one window and the same root')
+ scroll=field(view,'scroller')
+ scroll.ScrollToVerticalOffset(260);parent.UpdateLayout();pump()
offset=scroll.VerticalOffset
+ assert offset>0
updated=json.loads(sample.read_text());updated['ts']=time.time()+1
(scratch/'panel.json').write_text(json.dumps(updated),encoding='utf-8')
call(parent,'LoadModel');pump()
- assert abs(scroll.VerticalOffset-offset)<2
- checks.append('model refresh preserves scroll position')
- press(dashboard.Content,'Refresh')
+ assert field(view,'page')=='dashboard' and abs(scroll.VerticalOffset-offset)<2
+ checks.append('data refresh preserves the dashboard page and scroll position')
+ press(root,'Refresh usage now')
assert (scratch/'refresh.request').exists()
- checks.append('dashboard Refresh requests collection through the tray')
- call(dashboard,'ChangeTheme',False);pump()
- assert not field(dashboard,'light')
- assert abs(field(dashboard,'scroll').VerticalOffset-offset)<2
- checks.append('theme changes retain the window and scroll position')
- assert (scratch/'panel.visible').exists()
- dashboard.Close();pump()
- assert field(parent,'dashboard') is None
+ checks.append('the popup header Refresh still requests collection')
+ press(root,'Back to usage');pump()
+ assert field(view,'page')=='usage' and scroll.VerticalOffset<2
press(root,'Dashboard');pump()
- assert field(parent,'dashboard') is not None
- checks.append('closing and reopening creates one clean dashboard')
+ assert field(view,'page')=='dashboard' and scroll.VerticalOffset<2
+ checks.append('Dashboard and Back reset scroll on navigation')
+ for element in walk(root):
+ if isinstance(element,TextBlock) and element.ActualWidth>0:
+ right=element.TranslatePoint(Point(element.ActualWidth,0),root).X
+ assert right<=root.ActualWidth-28+1, ('text extends past popup',element.Text,right)
+ if element.Text.startswith('$') or (element.Text[:1].isdigit() and len(element.Text)<12):
+ assert element.ActualHeight < element.FontSize*1.9, ('numeric cell wraps',element.Text)
+ checks.append('dashboard text and numeric cells fit the popup column')
+ press(root,'Back to usage');pump()
+ args=Array[Object]([IntPtr.Zero,Int32(0x8049),IntPtr.Zero,IntPtr.Zero,Boolean(False)])
+ parent.GetType().GetMethod('OnMessage',flags).Invoke(parent,args);pump()
+ assert field(view,'page')=='dashboard' and app.Windows.Count==1
+ checks.append('the tray dashboard message selects the same popup page')
+ press(root,'Back to usage');pump()
+ (scratch/'dashboard.show').write_text('fresh request')
+ call(parent,'OnSignalTick');pump()
+ assert field(view,'page')=='dashboard' and app.Windows.Count==1
+ press(root,'Back to usage');pump()
+ (scratch/'dashboard.show').write_text('old request')
+ os.utime(scratch/'dashboard.show',(time.time()-30,time.time()-30))
+ call(parent,'OnSignalTick')
+ assert field(view,'page')=='usage' and not (scratch/'dashboard.show').exists()
+ checks.append('fresh file requests select Dashboard; stale requests are ignored')
+ call(parent,'Finish');pump()
+ assert field(view,'page')=='usage' and scroll.VerticalOffset<2
+ assert app.Windows.Count==1
assert not (scratch/'panel-error.log').exists()
- checks.append('no native errors; all actions use isolated sample data')
+ checks.append('closing resets to usage; no extra window or native error')
except Exception as error:
checks.append('FAILED: '+str(error))
finally: