feat(windows): Capacity Dock, an edge-docked quota rail

A slim always-on-top rail on the right screen edge showing one capacity ring
per signed-in provider, the Windows twin of the macOS Capacity Dock.

The window is created on demand and closed when the dock is switched off, so
a disabled dock leaves no webview resident. Hovering widens the same window
to reveal a detail panel with each quota window, its used percent, plan and
reset countdown; the panel unmounts on collapse rather than lingering hidden.

Data comes from `codeburn quota --format json` through the existing cli.rs
spawn plumbing. A CLI without that subcommand exits with `unknown command`,
which the rail reports as a quiet update-needed state instead of an error.

Metrics are the mac dock's whole-point values at its default 0.6 scale.
This commit is contained in:
iamtoruk 2026-09-01 13:50:56 -07:00
parent bbc0e222c8
commit 672196bb8f
7 changed files with 573 additions and 5 deletions

View file

@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for the CodeBurn tray app window",
"windows": ["popover"],
"windows": ["popover", "dock"],
"permissions": [
"core:default",
"core:window:allow-close",

View file

@ -17,6 +17,7 @@ const MAX_PAYLOAD_BYTES: usize = 20 * 1024 * 1024;
const MAX_STDERR_BYTES: usize = 256 * 1024;
const FETCH_TIMEOUT_SECS: u64 = 60;
const VERSION_TIMEOUT_SECS: u64 = 20;
const QUOTA_TIMEOUT_SECS: u64 = 45;
/// Oldest CLI this app can talk to. 0.9.9 is the first release whose
/// `status --format menubar-json` accepts `--no-optimize`, which every quiet background
@ -62,6 +63,15 @@ pub struct CliStatus {
pub error: Option<String>,
}
/// What the Capacity Dock renders: the provider array, or the reason there isn't one.
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "state", rename_all = "camelCase")]
pub enum DockQuota {
Ready { providers: Value },
CliOutdated,
Unavailable { message: String },
}
impl CodeburnCli {
/// Honours `CODEBURN_BIN` only when every whitespace-delimited token passes the
/// allowlist. Otherwise resolves `codeburn` from PATH and the usual npm locations.
@ -163,6 +173,38 @@ impl CodeburnCli {
Ok(payload)
}
/// Spawns `codeburn quota --format json` for the Capacity Dock. A CLI without the
/// subcommand exits 1 with `unknown command`, which the dock reports as a quiet
/// "CLI update needed" state rather than an error.
pub async fn fetch_quota(&self) -> DockQuota {
let stdout = match self
.run_capture(&["quota", "--format", "json"], QUOTA_TIMEOUT_SECS)
.await
{
Ok(stdout) => stdout,
Err(err) => {
let message = err.to_string();
return if message.contains("unknown command") {
DockQuota::CliOutdated
} else {
DockQuota::Unavailable { message }
};
}
};
match serde_json::from_str::<Value>(&stdout) {
Ok(payload) => DockQuota::Ready {
providers: payload
.get("providers")
.cloned()
.unwrap_or_else(|| Value::Array(vec![])),
},
Err(err) => DockQuota::Unavailable {
message: format!("CLI returned invalid JSON: {err}"),
},
}
}
async fn run_capture(&self, args: &[&str], timeout_secs: u64) -> Result<String> {
let mut full_args = self.extra_args.clone();
full_args.extend(args.iter().map(|s| s.to_string()));

View file

@ -0,0 +1,111 @@
//! The Capacity Dock: a slim quota rail docked to the right screen edge, the Windows twin of
//! the macOS Capacity Dock. The window is created on demand and closed again when the dock is
//! switched off, so a disabled dock leaves no webview resident.
use std::fs;
use std::path::PathBuf;
use anyhow::Result;
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
pub const DOCK_LABEL: &str = "dock";
/// Whole-point metrics at the macOS dock's default 0.6 scale: its 88pt rail lands on 53, which
/// `layout.activityBarWidth` in tokens.json already rounds to 56 for this frontend. Fractional
/// sizes cost the mac app 5-7% idle CPU by making the hosting view re-lay itself out forever,
/// so every value here stays an integer.
const RAIL_WIDTH: f64 = 56.0;
const ROW_HEIGHT: f64 = 50.0;
const ROW_SPACING: f64 = 7.0;
const RAIL_PAD: f64 = 12.0;
const DETAIL_WIDTH: f64 = 300.0;
fn state_path() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".config/codeburn"))
.unwrap_or_else(|| PathBuf::from(".codeburn"))
.join("windows-dock.json")
}
pub fn is_enabled() -> bool {
fs::read(state_path())
.ok()
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
.and_then(|value| value.get("enabled").and_then(serde_json::Value::as_bool))
.unwrap_or(false)
}
pub fn set_enabled(enabled: bool) -> Result<()> {
let path = state_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let body = serde_json::to_vec_pretty(&serde_json::json!({ "enabled": enabled }))?;
fs::write(&path, body)?;
Ok(())
}
fn logical_height(rows: u32) -> f64 {
let rows = rows.max(1) as f64;
RAIL_PAD * 2.0 + rows * ROW_HEIGHT + (rows - 1.0) * ROW_SPACING
}
/// Right edge, vertically centred, sized to the provider count. Expanding keeps the right edge
/// flush and grows leftwards, so the rail never moves under the pointer.
pub fn apply_layout(window: &tauri::WebviewWindow, rows: u32, expanded: bool) {
let Ok(Some(monitor)) = window.primary_monitor() else {
return;
};
let scale = monitor.scale_factor();
let width = if expanded {
RAIL_WIDTH + DETAIL_WIDTH
} else {
RAIL_WIDTH
};
let w = (width * scale).round() as i32;
let h = (logical_height(rows) * scale).round() as i32;
let area = monitor.work_area();
let x = area.position.x + area.size.width as i32 - w;
let y = area.position.y + ((area.size.height as i32 - h) / 2).max(0);
let _ = window.set_size(tauri::PhysicalSize::new(w.max(1) as u32, h.max(1) as u32));
let _ = window.set_position(tauri::PhysicalPosition::new(x, y));
}
pub fn show(app: &AppHandle) -> tauri::Result<()> {
if let Some(existing) = app.get_webview_window(DOCK_LABEL) {
existing.show()?;
return Ok(());
}
let builder = WebviewWindowBuilder::new(app, DOCK_LABEL, WebviewUrl::default())
.title("CodeBurn Capacity Dock")
.inner_size(RAIL_WIDTH, logical_height(1))
.decorations(false)
.resizable(false)
.always_on_top(true)
.skip_taskbar(true)
.focused(false)
.shadow(false)
.visible(false);
// The page draws its own rounded card, so the window behind it has to be see-through.
// `transparent` is only compiled in off macOS, where it needs the private-API feature.
#[cfg(not(target_os = "macos"))]
let builder = builder.transparent(true);
let window = builder.build()?;
apply_layout(&window, 1, false);
window.show()?;
Ok(())
}
/// Closing rather than hiding is deliberate: a hidden webview keeps rendering, which is what
/// cost the macOS popover 6% idle CPU.
pub fn hide(app: &AppHandle) {
if let Some(window) = app.get_webview_window(DOCK_LABEL) {
let _ = window.close();
}
}

View file

@ -1,6 +1,7 @@
mod autostart;
mod cli;
mod config;
mod dock;
mod fx;
mod plan;
/// The spend-in-the-tray badge is a second tray icon, which only the Tauri tray backend
@ -22,10 +23,13 @@ use tauri::Listener;
#[cfg(not(target_os = "linux"))]
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
};
#[cfg(not(target_os = "linux"))]
static DOCK_MENU_ITEM: std::sync::OnceLock<CheckMenuItem<tauri::Wry>> = std::sync::OnceLock::new();
use crate::cli::CodeburnCli;
use crate::config::CurrencyConfig;
use crate::fx::FxCache;
@ -78,9 +82,18 @@ pub fn run() {
round_window_corners(&window);
}
if dock::is_enabled() {
let _ = dock::show(app.handle());
}
Ok(())
})
.on_window_event(|window, event| {
// The dock is a persistent rail: it owns its own lifecycle and must survive both
// the hide-on-blur and the hide-on-close that keep the popover alive.
if window.label() == dock::DOCK_LABEL {
return;
}
match event {
WindowEvent::CloseRequested { api, .. } => {
api.prevent_close();
@ -108,6 +121,8 @@ pub fn run() {
commands::plan_usage,
commands::launch_at_login,
commands::set_launch_at_login,
commands::dock_quota,
commands::dock_set_layout,
])
.build(tauri::generate_context!())
.expect("error while running tauri application")
@ -132,6 +147,14 @@ fn build_tray_tauri(app: &AppHandle) -> tauri::Result<()> {
let open = MenuItem::with_id(app, "open", "Open CodeBurn", true, None::<&str>)?;
let refresh = MenuItem::with_id(app, "refresh", "Refresh", true, None::<&str>)?;
let theme = MenuItem::with_id(app, "toggle_theme", "Toggle Dark/Light", true, None::<&str>)?;
let capacity_dock = CheckMenuItem::with_id(
app,
"toggle_dock",
"Show Capacity Dock",
true,
dock::is_enabled(),
None::<&str>,
)?;
let report = MenuItem::with_id(app, "report", "Open Full Report", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit CodeBurn", true, None::<&str>)?;
let menu = Menu::with_items(
@ -140,11 +163,15 @@ fn build_tray_tauri(app: &AppHandle) -> tauri::Result<()> {
&open,
&refresh,
&theme,
&capacity_dock,
&report,
&PredefinedMenuItem::separator(app)?,
&quit,
],
)?;
// Both tray icons share one menu, so the handler needs the item itself to keep the
// checkmark in step with the persisted state.
let _ = DOCK_MENU_ITEM.set(capacity_dock);
tray.set_menu(Some(menu.clone()))?;
tray.set_show_menu_on_left_click(false)?;
@ -190,6 +217,7 @@ fn on_tray_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) {
let _ = window.emit("codeburn://toggle-theme", ());
}
}
"toggle_dock" => set_dock_enabled(app, !dock::is_enabled()),
"report" => {
let _ = cli::spawn_in_terminal(app, &["report"]);
}
@ -290,6 +318,24 @@ fn mark_hidden(app: &AppHandle) {
let _ = app.emit("codeburn://hidden", ());
}
/// Persists the dock preference, then brings the window in line with it. The checkmark is only
/// moved once the state is stored, so a failed write cannot leave the menu lying.
fn set_dock_enabled(app: &AppHandle, enabled: bool) {
if let Err(err) = dock::set_enabled(enabled) {
eprintln!("codeburn: failed to persist the Capacity Dock setting: {err}");
return;
}
if enabled {
let _ = dock::show(app);
} else {
dock::hide(app);
}
#[cfg(not(target_os = "linux"))]
if let Some(item) = DOCK_MENU_ITEM.get() {
let _ = item.set_checked(enabled);
}
}
fn toggle_popover(app: &AppHandle, anchor: Option<(i32, i32)>) {
let Some(window) = app.get_webview_window(POPOVER_LABEL) else {
return;
@ -526,4 +572,19 @@ mod commands {
pub async fn plan_usage(state: State<'_, AppState>) -> Result<crate::plan::PlanUsage, String> {
state.plan.fetch().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn dock_quota(state: State<'_, AppState>) -> Result<crate::cli::DockQuota, String> {
let cli = state.cli.lock().map_err(|e| e.to_string())?.clone();
Ok(cli.fetch_quota().await)
}
/// The rail only knows its own provider count and hover state; the geometry that turns
/// those into a window lives in `dock`.
#[tauri::command]
pub fn dock_set_layout(app: AppHandle, rows: u32, expanded: bool) {
if let Some(window) = app.get_webview_window(crate::dock::DOCK_LABEL) {
crate::dock::apply_layout(&window, rows, expanded);
}
}
}

181
windows/src/Dock.tsx Normal file
View file

@ -0,0 +1,181 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import './dock.css'
const REFRESH_MS = 5 * 60 * 1000
const HOVER_CLOSE_MS = 180
type QuotaWindow = {
label: string
usedPct: number
resetsAt?: string
}
type Provider = {
id: string
name: string
available: boolean
plan?: string
windows: QuotaWindow[]
error?: string
}
type DockQuota =
| { state: 'ready'; providers: Provider[] }
| { state: 'cliOutdated' }
| { state: 'unavailable'; message: string }
const RING_SIZE = 32
const RING_STROKE = 3
const RADIUS = (RING_SIZE - RING_STROKE) / 2
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
function headline(provider: Provider): QuotaWindow | null {
if (provider.windows.length === 0) return null
return provider.windows.reduce((worst, row) => (row.usedPct > worst.usedPct ? row : worst))
}
// Whole percentage points, matching the mac dock's rounded metrics.
function pct(value: number): number {
return Math.min(100, Math.max(0, Math.round(value)))
}
function resetsIn(iso?: string): string {
if (!iso) return ''
const seconds = (new Date(iso).getTime() - Date.now()) / 1000
if (Number.isNaN(seconds)) return ''
if (seconds < 60) return 'now'
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}d ${hours % 24}h`
if (hours > 0) return `${hours}h ${minutes % 60}m`
return `${minutes}m`
}
function Ring({ provider }: { provider: Provider }) {
const top = headline(provider)
const used = top ? pct(top.usedPct) : 0
return (
<div className="dock-row" data-provider={provider.id} title={provider.name}>
<svg className="dock-ring" width={RING_SIZE} height={RING_SIZE} viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}>
<circle
className="dock-ring-track"
cx={RING_SIZE / 2}
cy={RING_SIZE / 2}
r={RADIUS}
strokeWidth={RING_STROKE}
/>
<circle
className="dock-ring-arc"
cx={RING_SIZE / 2}
cy={RING_SIZE / 2}
r={RADIUS}
strokeWidth={RING_STROKE}
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={CIRCUMFERENCE * (1 - used / 100)}
/>
</svg>
<span className={`dock-pct${used >= 90 ? ' is-spent' : ''}`}>{top ? `${used}%` : '--'}</span>
</div>
)
}
function notice(quota: DockQuota | null, providers: Provider[]): string | null {
if (quota?.state === 'cliOutdated') return 'CLI update needed for live quota. Run npm install -g codeburn.'
if (quota?.state === 'unavailable') return 'Quota is unavailable right now.'
if (quota?.state === 'ready' && providers.length === 0) return 'No signed in providers to show.'
return null
}
function Detail({ providers, message }: { providers: Provider[]; message: string | null }) {
return (
<div className="dock-detail">
{message ? <p className="dock-notice">{message}</p> : null}
{providers.map((provider) => (
<section className="dock-detail-card" key={provider.id} data-provider={provider.id}>
<header className="dock-detail-head">
<span className="dock-detail-name">{provider.name}</span>
{provider.plan ? <span className="dock-detail-plan">{provider.plan}</span> : null}
</header>
{provider.windows.map((row) => {
const used = pct(row.usedPct)
const resets = resetsIn(row.resetsAt)
return (
<div className="dock-window" key={`${provider.id}-${row.label}`}>
<div className="dock-window-top">
<span className="dock-window-label">{row.label}</span>
<span className="dock-window-pct">{used}%</span>
</div>
<div className="dock-track">
<div className="dock-fill" style={{ width: `${used}%` }} />
</div>
{resets ? <span className="dock-window-resets">Resets in {resets}</span> : null}
</div>
)
})}
</section>
))}
</div>
)
}
export function Dock() {
const [quota, setQuota] = useState<DockQuota | null>(null)
const [expanded, setExpanded] = useState(false)
const closeTimer = useRef<number | undefined>(undefined)
const load = useCallback(async () => {
try {
setQuota(await invoke<DockQuota>('dock_quota'))
} catch (err) {
setQuota({ state: 'unavailable', message: String(err) })
}
}, [])
useEffect(() => {
void load()
const timer = window.setInterval(() => void load(), REFRESH_MS)
return () => window.clearInterval(timer)
}, [load])
const providers =
quota?.state === 'ready' ? quota.providers.filter((provider) => provider.available) : []
const rows = Math.max(providers.length, 1)
useEffect(() => {
void invoke('dock_set_layout', { rows, expanded })
}, [rows, expanded])
useEffect(() => () => window.clearTimeout(closeTimer.current), [])
const message = notice(quota, providers)
const open = () => {
window.clearTimeout(closeTimer.current)
if (providers.length > 0 || message) setExpanded(true)
}
// A short grace period keeps the panel from flickering shut as the pointer crosses the
// seam between the rail and the panel.
const close = () => {
window.clearTimeout(closeTimer.current)
closeTimer.current = window.setTimeout(() => setExpanded(false), HOVER_CLOSE_MS)
}
return (
<div className={`dock${expanded ? ' is-expanded' : ''}`} onMouseEnter={open} onMouseLeave={close}>
{expanded ? <Detail providers={providers} message={message} /> : null}
<div className="dock-rail" onDoubleClick={() => void load()}>
{providers.map((provider) => (
<Ring provider={provider} key={provider.id} />
))}
{providers.length === 0 ? (
<div className="dock-row">
<span className="dock-quiet">{quota?.state === 'cliOutdated' ? 'CLI' : '--'}</span>
</div>
) : null}
</div>
</div>
)
}

170
windows/src/dock.css Normal file
View file

@ -0,0 +1,170 @@
/* Capacity Dock rail. Metrics are the macOS dock's whole-point values at its default 0.6
scale (mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift); the window that holds
them is sized in src-tauri/src/dock.rs and the two must stay in step. */
:root {
--dock-rail-w: 56px;
--dock-row-h: 50px;
--dock-row-gap: 7px;
--dock-pad: 12px;
--dock-detail-w: 300px;
--dock-radius: 10px;
/* claude, codex and copilot are tokens.json color.categorical; the remaining three keep
the ring colours the macOS Capacity Dock already gives them. */
--dock-claude: #C9521D;
--dock-codex: #5C7CA3;
--dock-copilot: #B8944C;
--dock-gemini: #478CFA;
--dock-kimi: #E6F014;
--dock-antigravity: #FF7A45;
}
.dock {
display: flex;
justify-content: flex-end;
height: 100%;
overflow: hidden;
font-family: var(--font-sans);
}
.dock-rail {
flex: 0 0 var(--dock-rail-w);
display: flex;
flex-direction: column;
gap: var(--dock-row-gap);
padding: var(--dock-pad) 0;
background: var(--surface);
border-radius: var(--dock-radius) 0 0 var(--dock-radius);
border: 1px solid var(--separator);
border-right: none;
box-shadow: var(--menu-shadow);
}
.dock-row {
height: var(--dock-row-h);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
}
.dock-ring {
transform: rotate(-90deg);
}
.dock-ring-track {
fill: none;
stroke: var(--fill-12);
}
.dock-ring-arc {
fill: none;
stroke: var(--dock-accent, var(--label-2));
stroke-linecap: round;
transition: stroke-dashoffset 240ms var(--ease-ui);
}
.dock-pct {
font-size: 10px;
font-variant-numeric: tabular-nums;
color: var(--label-2);
}
.dock-pct.is-spent {
color: var(--danger);
}
.dock-quiet {
font-size: 10px;
color: var(--label-3);
}
.dock-detail {
flex: 0 0 var(--dock-detail-w);
overflow-y: auto;
padding: var(--dock-pad);
display: flex;
flex-direction: column;
gap: 10px;
background: var(--surface);
border-radius: var(--dock-radius) 0 0 var(--dock-radius);
border: 1px solid var(--separator);
border-right: none;
box-shadow: var(--menu-shadow);
}
.dock-notice {
margin: 0;
font-size: 11px;
color: var(--label-2);
}
.dock-detail-card {
display: flex;
flex-direction: column;
gap: 6px;
}
.dock-detail-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.dock-detail-name {
font-size: 12px;
font-weight: 600;
color: var(--label);
}
.dock-detail-plan {
font-size: 10px;
color: var(--label-2);
}
.dock-window {
display: flex;
flex-direction: column;
gap: 3px;
}
.dock-window-top {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 11px;
color: var(--label-2);
}
.dock-window-pct {
font-variant-numeric: tabular-nums;
color: var(--label);
}
.dock-track {
height: 4px;
border-radius: var(--radius-bar);
background: var(--fill-12);
overflow: hidden;
}
.dock-fill {
height: 100%;
border-radius: var(--radius-bar);
background: var(--dock-accent, var(--label-2));
}
.dock-window-resets {
font-size: 10px;
color: var(--label-3);
}
[data-provider='claude'] { --dock-accent: var(--dock-claude); }
[data-provider='codex'] { --dock-accent: var(--dock-codex); }
[data-provider='copilot'] { --dock-accent: var(--dock-copilot); }
[data-provider='gemini'] { --dock-accent: var(--dock-gemini); }
[data-provider='kimi'] { --dock-accent: var(--dock-kimi); }
[data-provider='antigravity'] { --dock-accent: var(--dock-antigravity); }

View file

@ -1,10 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { App } from './App'
import { Dock } from './Dock'
import './styles.css'
// Both windows load the one bundle, so the label decides which surface mounts.
const isDock = getCurrentWindow().label === 'dock'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
<React.StrictMode>{isDock ? <Dock /> : <App />}</React.StrictMode>
)