diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 26f604318c1..81d33c5db70 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -19,6 +19,7 @@ const clientSettings: ClientSettings = { dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, favorites: [], + fileExplorerShowDotfiles: true, headerGitActionsVisibility: "auto", headerOpenInEditorVisibility: "auto", headerProjectScriptsVisibility: "auto", @@ -33,6 +34,7 @@ const clientSettings: ClientSettings = { providerModelPreferences: {}, sidebarChatListView: "grouped", sidebarHostStatsVisible: false, + sidebarHostStatsStyle: "classic", sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1b737a8d22b..24415d8a912 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -210,7 +210,8 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; -import { useHostStats } from "~/hooks/useHostStats"; +import { useHostStatsWithHistory } from "~/hooks/useHostStats"; +import { HOST_STATS_VARIANTS } from "~/components/host-stats/variants"; import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, @@ -3298,37 +3299,19 @@ function T3Wordmark() { ); } -// Compact "3.2/15.6 GB" style figure for the footer host-stats readout. -function formatFooterGigabytes(bytes: number): string { - const gigabytes = bytes / 1024 ** 3; - if (gigabytes >= 100) return String(Math.round(gigabytes)); - return gigabytes.toFixed(1); -} - // Ambient CPU/memory telemetry for the T3 server host, shown next to the // Settings button when the "Server load" toggle (Settings → Features) is on. +// The visual style is selectable there too; see components/host-stats/. const SidebarHostStats = memo(function SidebarHostStats() { const visible = useClientSettings((settings) => settings.sidebarHostStatsVisible); - const stats = useHostStats(visible); + const style = useClientSettings((settings) => settings.sidebarHostStatsStyle); + const { stats, history } = useHostStatsWithHistory(visible); if (!visible || stats === null) { return null; } - const cpuLabel = `${Math.round(stats.cpuPercent)}%`; - const memLabel = `${formatFooterGigabytes(stats.memUsedBytes)}/${formatFooterGigabytes(stats.memTotalBytes)}G`; - const coreLabel = stats.cpuCount === 1 ? "1 core" : `${stats.cpuCount} cores`; - const detail = `Server load — CPU ${stats.cpuPercent.toFixed(1)}% of ${coreLabel} · memory ${formatFooterGigabytes(stats.memUsedBytes)} of ${formatFooterGigabytes(stats.memTotalBytes)} GB`; - - return ( -
- CPU {cpuLabel} - MEM {memLabel} -
- ); + const { Component } = HOST_STATS_VARIANTS[style]; + return ; }); const SidebarChromeFooter = memo(function SidebarChromeFooter() { diff --git a/apps/web/src/components/host-stats/VariantBadge.tsx b/apps/web/src/components/host-stats/VariantBadge.tsx new file mode 100644 index 00000000000..faf878d608e --- /dev/null +++ b/apps/web/src/components/host-stats/VariantBadge.tsx @@ -0,0 +1,50 @@ +import { cn } from "../../lib/utils"; +import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; + +export function VariantBadge({ stats, history }: HostStatsVariantProps) { + void history; + const detail = hostStatsDetail(stats); + + const cpu = Math.max(0, Math.min(100, stats.cpuPercent)); + const memPercent = + stats.memTotalBytes > 0 ? (stats.memUsedBytes / stats.memTotalBytes) * 100 : 0; + + const cpuStatus = cpu >= 85 ? "destructive" : cpu >= 60 ? "warning" : "success"; + const memStatus = memPercent >= 85 ? "destructive" : memPercent >= 60 ? "warning" : "success"; + + const statusPriority = { destructive: 3, warning: 2, success: 1 }; + const overallStatus = + statusPriority[cpuStatus] > statusPriority[memStatus] ? cpuStatus : memStatus; + + const containerColorClasses = { + success: "bg-success/10 border-success/20", + warning: "bg-warning/10 border-warning/20", + destructive: "bg-destructive/10 border-destructive/20", + }[overallStatus]; + + const dotColorClasses = { + success: "bg-success", + warning: "bg-warning animate-pulse", + destructive: "bg-destructive animate-pulse", + }[overallStatus]; + + return ( +
+
+
+ CPU + {Math.round(cpu)}% + · + MEM + {formatFooterGigabytes(stats.memUsedBytes)}G +
+
+ ); +} diff --git a/apps/web/src/components/host-stats/VariantBars.tsx b/apps/web/src/components/host-stats/VariantBars.tsx new file mode 100644 index 00000000000..867f2037230 --- /dev/null +++ b/apps/web/src/components/host-stats/VariantBars.tsx @@ -0,0 +1,64 @@ +import { cn } from "../../lib/utils"; +import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; + +/** Map a 0–100 load into the app's status tokens: <60 success, 60–85 warning, >85 destructive. */ +function loadTone(percent: number): { fill: string; text: string } { + if (percent > 85) return { fill: "bg-destructive", text: "text-destructive-foreground" }; + if (percent >= 60) return { fill: "bg-warning", text: "text-warning-foreground" }; + return { fill: "bg-success", text: "text-success-foreground" }; +} + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(100, Math.max(0, value)); +} + +interface MeterRowProps { + readonly label: string; + readonly percent: number; + readonly value: string; +} + +function MeterRow({ label, percent, value }: MeterRowProps) { + const tone = loadTone(percent); + return ( +
+ + {label} + + + + + {value} +
+ ); +} + +/** + * Two slim stacked meter bars — CPU on top, MEM below — with a color-ramped + * fill and a right-aligned tabular value, all on a shared three-column grid so + * tracks and figures line up. Fills glide on each 5s refresh. + */ +export function VariantBars({ stats }: HostStatsVariantProps) { + const cpuPercent = clampPercent(stats.cpuPercent); + const memPercent = + stats.memTotalBytes > 0 ? clampPercent((stats.memUsedBytes / stats.memTotalBytes) * 100) : 0; + + const cpuValue = `${Math.round(cpuPercent)}%`; + const memValue = `${formatFooterGigabytes(stats.memUsedBytes)}/${formatFooterGigabytes(stats.memTotalBytes)}G`; + const detail = hostStatsDetail(stats); + + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/components/host-stats/VariantClassic.tsx b/apps/web/src/components/host-stats/VariantClassic.tsx new file mode 100644 index 00000000000..97524429e4d --- /dev/null +++ b/apps/web/src/components/host-stats/VariantClassic.tsx @@ -0,0 +1,19 @@ +import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; + +// The original plain-text readout: "CPU 12% MEM 3.2/15.6G". +export function VariantClassic({ stats }: HostStatsVariantProps) { + const cpuLabel = `${Math.round(stats.cpuPercent)}%`; + const memLabel = `${formatFooterGigabytes(stats.memUsedBytes)}/${formatFooterGigabytes(stats.memTotalBytes)}G`; + const detail = hostStatsDetail(stats); + + return ( +
+ CPU {cpuLabel} + MEM {memLabel} +
+ ); +} diff --git a/apps/web/src/components/host-stats/VariantEqualizer.tsx b/apps/web/src/components/host-stats/VariantEqualizer.tsx new file mode 100644 index 00000000000..cd99d0c2f7a --- /dev/null +++ b/apps/web/src/components/host-stats/VariantEqualizer.tsx @@ -0,0 +1,88 @@ +import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; + +import { cn } from "../../lib/utils"; + +const MAX_BARS = 24; +const BAR_WIDTH = 2; +const GAP = 1; +const TRACK_WIDTH = MAX_BARS * (BAR_WIDTH + GAP) - GAP; + +function clampCpu(value: number): number { + return Math.max(0, Math.min(100, value)); +} + +function loadTone(cpu: number): "success" | "warning" | "destructive" { + if (cpu > 85) return "destructive"; + if (cpu >= 60) return "warning"; + return "success"; +} + +export function VariantEqualizer({ stats, history }: HostStatsVariantProps) { + const detail = hostStatsDetail(stats); + const currentCpu = clampCpu(stats.cpuPercent); + const currentTone = loadTone(currentCpu); + + const bars = + history.length > 0 + ? history + : [ + { + at: 0, + cpuPercent: stats.cpuPercent, + memUsedBytes: stats.memUsedBytes, + memTotalBytes: stats.memTotalBytes, + }, + ]; + + return ( +
+
+ {Array.from({ length: MAX_BARS }, (_, slot) => { + const dataIndex = slot - (MAX_BARS - bars.length); + if (dataIndex < 0) { + return
; + } + const sample = bars[dataIndex]; + if (!sample) return null; + const cpu = clampCpu(sample.cpuPercent); + const tone = loadTone(cpu); + const opacity = bars.length === 1 ? 1 : 0.35 + (dataIndex / (bars.length - 1)) * 0.65; + return ( +
+ ); + })} +
+ +
+ + {Math.round(currentCpu)}% + + + {formatFooterGigabytes(stats.memUsedBytes)} + / + {formatFooterGigabytes(stats.memTotalBytes)}G + +
+
+ ); +} diff --git a/apps/web/src/components/host-stats/VariantRings.tsx b/apps/web/src/components/host-stats/VariantRings.tsx new file mode 100644 index 00000000000..82aaa08f17a --- /dev/null +++ b/apps/web/src/components/host-stats/VariantRings.tsx @@ -0,0 +1,94 @@ +import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; +import { cn } from "../../lib/utils"; + +// Two mini donut gauges (CPU + MEM): muted track circle with a round-capped +// progress arc color-ramped by load, starting at 12 o'clock, with the value +// and a tiny label beside it. Refreshes every 5s; the arc glides via a +// stroke-dashoffset transition. +const RING_RADIUS = 9; +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; + +function loadColorClass(pct: number): string { + if (pct >= 85) return "text-destructive"; + if (pct >= 60) return "text-warning"; + return "text-success"; +} + +interface RingGaugeProps { + readonly pct: number; + readonly colorClass: string; + readonly value: string; + readonly label: string; +} + +function RingGauge({ pct, colorClass, value, label }: RingGaugeProps) { + const offset = RING_CIRCUMFERENCE * (1 - pct / 100); + return ( +
+ +
+ {value} + + {label} + +
+
+ ); +} + +export function VariantRings({ stats }: HostStatsVariantProps) { + const detail = hostStatsDetail(stats); + const cpuPct = Math.min(100, Math.max(0, stats.cpuPercent)); + const memPct = + stats.memTotalBytes > 0 + ? Math.min(100, Math.max(0, (stats.memUsedBytes / stats.memTotalBytes) * 100)) + : 0; + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/host-stats/VariantSegments.tsx b/apps/web/src/components/host-stats/VariantSegments.tsx new file mode 100644 index 00000000000..012acf4c4b6 --- /dev/null +++ b/apps/web/src/components/host-stats/VariantSegments.tsx @@ -0,0 +1,77 @@ +import { cn } from "../../lib/utils"; +import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; + +const SEGMENT_COUNT = 10; +const SEGMENTS = Array.from({ length: SEGMENT_COUNT }, (_, index) => index); + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, Number.isFinite(value) ? value : 0)); +} + +function loadColor(percent: number): string { + if (percent > 85) return "bg-destructive"; + if (percent >= 60) return "bg-warning"; + return "bg-success"; +} + +interface SegmentMeterProps { + readonly percent: number; + readonly peakPercent: number; +} + +function SegmentMeter({ percent, peakPercent }: SegmentMeterProps) { + const litCount = Math.ceil((clampPercent(percent) / 100) * SEGMENT_COUNT); + const peakCount = Math.ceil((clampPercent(peakPercent) / 100) * SEGMENT_COUNT); + const color = loadColor(percent); + + return ( +