From 245762710fbe728f8d0b6449b59b53a03e843d0d Mon Sep 17 00:00:00 2001 From: musi Date: Tue, 14 Jul 2026 09:12:57 +0800 Subject: [PATCH] Refactor router config and request handling --- build/dev.mjs | 139 +++-- packages/electron/src/main/ipc.ts | 3 + packages/electron/src/main/main-app.ts | 4 +- packages/electron/src/main/native-theme.ts | 6 + packages/electron/src/main/tray-controller.ts | 42 +- .../src/pages/home/components/dashboard.tsx | 480 +++++++++------ .../ui/src/pages/home/shared/controls.tsx | 10 +- packages/ui/src/pages/tray/TrayApp.tsx | 25 +- packages/ui/src/pages/tray/TrayDetailApp.tsx | 12 +- .../pages/tray/components/account-panel.tsx | 8 +- .../src/pages/tray/components/source-grid.tsx | 44 +- .../pages/tray/components/status-strip.tsx | 8 +- .../pages/tray/components/usage-detail.tsx | 4 +- .../ui/src/pages/tray/components/widgets.tsx | 84 +-- packages/ui/src/pages/tray/shared.tsx | 113 +++- packages/ui/src/styles/globals.css | 556 +++++++++++++++++- tests/renderer/fixtures.ts | 9 +- tests/renderer/overview-components.test.tsx | 26 + tests/renderer/tray-components.test.tsx | 50 +- 19 files changed, 1244 insertions(+), 379 deletions(-) create mode 100644 packages/electron/src/main/native-theme.ts diff --git a/build/dev.mjs b/build/dev.mjs index fd87ff51..38901c2f 100644 --- a/build/dev.mjs +++ b/build/dev.mjs @@ -2,7 +2,7 @@ import electron from "electron"; import esbuild from "esbuild"; import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; -import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; import { buildStyles, @@ -97,13 +97,6 @@ function readyState() { .join(" "); } -function describeWatchEvent(label, watchedPath, eventType, filename, isDirectory = false) { - const changedPath = filename - ? path.join(isDirectory ? watchedPath : path.dirname(watchedPath), String(filename)) - : watchedPath; - return `${label} ${eventType} ${relativePath(changedPath)}`; -} - function contentSignature(targetPath) { try { return readContentSignature(targetPath); @@ -179,31 +172,14 @@ function listDirectoryFiles(targetPath, basePath = targetPath) { return files; } -function rememberWatchSignature(label, targetPath) { - const signature = contentSignature(targetPath); +function rememberWatchSignature(label, targetPath, options = {}) { + const signature = options.metadataOnly + ? metadataSignature(targetPath) + : contentSignature(targetPath); watchSignatures.set(label, signature.key); logDev(`watch baseline: ${label} ${relativePath(targetPath)}; ${signature.summary}`); } -function handleWatchedInput(label, watchedPath, eventType, filename, options, onChange) { - const reason = describeWatchEvent(label, watchedPath, eventType, filename, options?.isDirectory); - const signature = contentSignature(watchedPath); - const previousSignature = watchSignatures.get(label); - const changed = previousSignature !== signature.key; - watchSignatures.set(label, signature.key); - logDev(`watch event: ${reason}; ${signature.summary}; content=${changed ? "changed" : "unchanged"}`); - - if (!changed) { - logDev(`restart skipped: ${reason} (content unchanged)`); - return; - } - - onChange(); - if (enabled.electron && options?.restart !== false) { - scheduleRestart(reason); - } -} - function scheduleStyleBuild(reason) { queuedStyleBuildReason = reason; if (styleBuildTimer) { @@ -258,6 +234,64 @@ function pollStyleWatchRoots() { } } +function pollWatchedInput(label, targetPath, onChange, options = {}) { + const signature = options.metadataOnly + ? metadataSignature(targetPath) + : contentSignature(targetPath); + const previousSignature = watchSignatures.get(label); + if (previousSignature === signature.key) { + return; + } + + watchSignatures.set(label, signature.key); + logDev(`watch event: ${label} ${relativePath(targetPath)}; ${signature.summary}; content=changed`); + try { + onChange(); + if (enabled.electron && options.restart !== false) { + scheduleRestart(label); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logDev(`watch action failed: ${label}; ${message}`); + } +} + +function metadataSignature(targetPath) { + if (!existsSync(targetPath)) { + return { + key: "missing", + summary: "missing" + }; + } + + const stats = statSync(targetPath); + return { + key: `metadata:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}`, + summary: `size=${stats.size} mtime=${stats.mtime.toISOString()} ctime=${stats.ctime.toISOString()}` + }; +} + +function pollSourceWatchTargets() { + pollWatchedInput("home html", rendererHtmlInput, () => { + copyRendererHtml(); + syncUiRendererToRuntimeDists(); + }); + pollWatchedInput("browser html", browserRendererHtmlInput, () => { + copyBrowserRendererHtml(); + syncUiRendererToRuntimeDists(); + }); + pollWatchedInput("tray html", trayRendererHtmlInput, () => { + copyTrayRendererHtml(); + syncUiRendererToRuntimeDists(); + }); + if (enabled.electron) { + pollWatchedInput("app assets", appAssetsInput, copyAppAssets); + } + if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) { + pollWatchedInput("model catalog", modelCatalogInput, copyModelCatalog, { metadataOnly: true }); + } +} + function markReady(name, reason = `${name} esbuild completed`) { if (name === "browser" || name === "cli" || name === "main" || name === "renderer" || name === "tray" || name === "webBridge") { ready[name] = true; @@ -385,43 +419,13 @@ if (enabled.electron) { rememberWatchSignature("app assets", appAssetsInput); } if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) { - rememberWatchSignature("model catalog", modelCatalogInput); + rememberWatchSignature("model catalog", modelCatalogInput, { metadataOnly: true }); } -const htmlWatcher = watch(rendererHtmlInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, () => { - copyRendererHtml(); - syncUiRendererToRuntimeDists(); - }); -}); - -const browserHtmlWatcher = watch(browserRendererHtmlInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("browser html", browserRendererHtmlInput, eventType, filename, undefined, () => { - copyBrowserRendererHtml(); - syncUiRendererToRuntimeDists(); - }); -}); - -const trayHtmlWatcher = watch(trayRendererHtmlInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, () => { - copyTrayRendererHtml(); - syncUiRendererToRuntimeDists(); - }); -}); - -const stylePoller = setInterval(pollStyleWatchRoots, stylePollIntervalMs); - -const appAssetsWatcher = enabled.electron - ? watch(appAssetsInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets); - }) - : { close: () => undefined }; - -const modelCatalogWatcher = (enabled.cli || enabled.electron) && existsSync(modelCatalogInput) - ? watch(modelCatalogInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("model catalog", modelCatalogInput, eventType, filename, undefined, copyModelCatalog); - }) - : { close: () => undefined }; +const sourcePoller = setInterval(() => { + pollStyleWatchRoots(); + pollSourceWatchTargets(); +}, stylePollIntervalMs); const contexts = []; @@ -521,12 +525,7 @@ async function shutdown() { if (styleBuildTimer) { clearTimeout(styleBuildTimer); } - htmlWatcher.close(); - browserHtmlWatcher.close(); - trayHtmlWatcher.close(); - clearInterval(stylePoller); - appAssetsWatcher.close(); - modelCatalogWatcher.close(); + clearInterval(sourcePoller); await Promise.all(contexts.map((context) => context.dispose())); process.exit(0); } diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index ec9386ef..edf2ee74 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -32,6 +32,7 @@ import { getAgentAnalysis, getAgentTracePayload, getRequestLogDetail, getRequest import trayController from "./tray-controller"; import { appUpdateService } from "./update-service"; import { getUsageStats } from "@ccr/core/usage/store"; +import { applyNativeThemePreference } from "./native-theme"; import windowsManager from "./windows"; import type { AgentAnalysisFilter, AgentAnalysisTracePayloadRequest, ApiKeyConfig, AppCaptureElementPngRequest, AppCaptureElementPngResult, AppConfig, AppDataExportResult, AppImageExportTargetRequest, AppImageExportTargetResult, AppInfo, AppRenderHtmlPngRequest, AppRenderHtmlPngResult, AppSaveConfigOptions, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayPluginAppConfig, GatewayProviderConnectivityCheckRequest, GatewayProviderProbeCandidatesRequest, GatewayProviderProbeRequest, GatewayStatus, LocalAgentProviderImportRequest, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountResetRequest, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "@ccr/core/contracts/app"; @@ -181,6 +182,7 @@ ipcMain.handle(IPC_CHANNELS.appOpenProfile, async (_event, request: ProfileOpenR ipcMain.handle(IPC_CHANNELS.appApplyClaudeAppGateway, async (_event, config?: AppConfig) => { const previousConfig = await loadAppConfig(); const baseConfig = config ? await saveAppConfig(config) : previousConfig; + applyNativeThemePreference(baseConfig.theme); const synced = await syncClaudeAppGatewayConfig(baseConfig); const savedConfig = synced.config; let runtimeStatus = gatewayService.getStatus(); @@ -266,6 +268,7 @@ ipcMain.handle(IPC_CHANNELS.appSaveConfig, async (_event, config: AppConfig, opt } const launchAtLoginChanged = Boolean(config.launchAtLogin) !== Boolean(previousConfig.launchAtLogin); let savedConfig = await saveAppConfig(config); + applyNativeThemePreference(savedConfig.theme); if (launchAtLoginChanged) { try { syncLaunchAtLogin(savedConfig); diff --git a/packages/electron/src/main/main-app.ts b/packages/electron/src/main/main-app.ts index 4cc37d15..10eab767 100644 --- a/packages/electron/src/main/main-app.ts +++ b/packages/electron/src/main/main-app.ts @@ -13,6 +13,7 @@ import trayController from "./tray-controller"; import { appUpdateService } from "./update-service"; import { browserAutomationMcpService } from "./browser-automation-mcp"; import { browserWebSearchMcpService } from "./electron-web-search-mcp"; +import { applyNativeThemePreference } from "./native-theme"; import windowsManager from "./windows"; const gotTheLock = app.requestSingleInstanceLock(); @@ -41,7 +42,8 @@ function startPrimaryInstance(): void { queueEnsureConfiguredProxyModeActive("second-instance"); }); - void app.whenReady().then(() => { + void app.whenReady().then(async () => { + applyNativeThemePreference((await loadAppConfig()).theme); configureProxyDesktopIntegration(); let ccrLauncherPreparation: CcrCliLauncherPreparation | undefined; try { diff --git a/packages/electron/src/main/native-theme.ts b/packages/electron/src/main/native-theme.ts new file mode 100644 index 00000000..840ec04b --- /dev/null +++ b/packages/electron/src/main/native-theme.ts @@ -0,0 +1,6 @@ +import { nativeTheme } from "electron"; +import type { AppConfig } from "@ccr/core/contracts/app"; + +export function applyNativeThemePreference(theme: AppConfig["theme"] | undefined): void { + nativeTheme.themeSource = theme === "light" || theme === "dark" ? theme : "system"; +} diff --git a/packages/electron/src/main/tray-controller.ts b/packages/electron/src/main/tray-controller.ts index b3d287cd..67c2969f 100644 --- a/packages/electron/src/main/tray-controller.ts +++ b/packages/electron/src/main/tray-controller.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, Menu, Tray, app, nativeImage, screen } from "electron"; +import { BrowserWindow, Menu, Tray, app, nativeImage, screen, type BrowserWindowConstructorOptions } from "electron"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { deflateSync } from "node:zlib"; @@ -17,7 +17,21 @@ const popoverDetailWidth = 420; const popoverMargin = 8; const trayActivationSuppressMs = 750; const trayMenuBarIconSize = 20; -const trayWindowBackgroundColor = "#020617"; +const trayWindowBackgroundColor = "#1c1c1e"; +const trayWindowMaterialOptions: Pick< + BrowserWindowConstructorOptions, + "backgroundColor" | "transparent" | "vibrancy" | "visualEffectState" +> = process.platform === "darwin" + ? { + backgroundColor: "#00000000", + transparent: true, + vibrancy: "under-window", + visualEffectState: "active" + } + : { + backgroundColor: trayWindowBackgroundColor, + transparent: false + }; const trayTokenFallbackTitle = "0 tokens"; const trayIconFallbackPath = path.join(__dirname, "../assets/tray.png"); const trayMascotIconIds = ["violet", "orange", "cyan"] as const; @@ -190,7 +204,6 @@ class TrayController { this.popover = new BrowserWindow({ acceptFirstMouse: true, alwaysOnTop: true, - backgroundColor: trayWindowBackgroundColor, frame: false, fullscreenable: false, hasShadow: true, @@ -203,7 +216,7 @@ class TrayController { show: false, skipTaskbar: true, title: `${APP_NAME} Usage`, - transparent: false, + ...trayWindowMaterialOptions, webPreferences: { contextIsolation: true, nodeIntegration: false, @@ -215,6 +228,7 @@ class TrayController { width: popoverMenuWidth }); + reinforceMacOSTrayMaterial(this.popover); prepareTrayWindowForSharpRendering(this.popover); this.popover.setAlwaysOnTop(true, "pop-up-menu"); this.popover.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); @@ -235,7 +249,6 @@ class TrayController { this.detailPopover = new BrowserWindow({ acceptFirstMouse: true, alwaysOnTop: true, - backgroundColor: trayWindowBackgroundColor, frame: false, fullscreenable: false, hasShadow: true, @@ -248,7 +261,7 @@ class TrayController { show: false, skipTaskbar: true, title: `${APP_NAME} Usage Detail`, - transparent: false, + ...trayWindowMaterialOptions, webPreferences: { contextIsolation: true, nodeIntegration: false, @@ -260,6 +273,7 @@ class TrayController { width: popoverDetailWidth }); + reinforceMacOSTrayMaterial(this.detailPopover); prepareTrayWindowForSharpRendering(this.detailPopover); this.detailPopover.setAlwaysOnTop(true, "pop-up-menu"); this.detailPopover.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); @@ -563,6 +577,22 @@ function normalizeDetailProvider(provider?: string): string | undefined { return trimmed ? trimmed : undefined; } +function reinforceMacOSTrayMaterial(window: BrowserWindow): void { + if (process.platform !== "darwin") { + return; + } + + const applyMaterial = () => { + if (!window.isDestroyed()) { + window.setBackgroundColor("#00000000"); + window.setVibrancy("under-window"); + } + }; + + applyMaterial(); + window.webContents.on("did-finish-load", applyMaterial); +} + function prepareTrayWindowForSharpRendering(window: BrowserWindow): void { const resetZoom = () => { if (!window.isDestroyed() && !window.webContents.isDestroyed()) { diff --git a/packages/ui/src/pages/home/components/dashboard.tsx b/packages/ui/src/pages/home/components/dashboard.tsx index 8279521b..c61c4396 100644 --- a/packages/ui/src/pages/home/components/dashboard.tsx +++ b/packages/ui/src/pages/home/components/dashboard.tsx @@ -3,20 +3,20 @@ import { AnimatePresence, AnimatedDisclosure, AnimatedIconSwap, Area, arrayMove, Badge, Bar, BarChart, Button, Card, CardContent, CardHeader, CardTitle, CartesianGrid, Cell, constrainOverviewWidgetSize, - Check, ChevronDown, ChevronLeft, ChevronRight, CircleAlert, cn, compactId, + Check, ChevronDown, ChevronRight, CircleAlert, cn, compactId, compactUserAgent, compareProviderAccountSnapshots, ComposedChart, CSS, DEFAULT_OVERVIEW_WIDGETS, DndContext, Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle, DragEndEvent, DragOverEvent, DragOverlay, DragStartEvent, Field, formatAxisNumber, formatBytes, formatCompactNumber, formatDuration, formatLogDateTime, formatPercent, formatProviderAccountDetailDate, formatProviderAccountMeterTitle, formatProviderAccountMeterValue, formatStatusBucketDate, formatStatusCodeCounts, formatSystemStatusRange, formatToolCounts, formatUsdCost, KeyboardSensor, - LabelList, LayoutGroup, Line, LoaderCircle, MeasuringStrategy, MetricCard, MetricTone, - metricToneBar, metricToneStroke, motion, normalizeAgentFilterValue, normalizeOverviewWidget, normalizeOverviewWidgets, + LabelList, LayoutGroup, Line, LoaderCircle, MeasuringStrategy, MetricTone, + motion, normalizeAgentFilterValue, normalizeOverviewWidget, normalizeOverviewWidgets, OverviewMetricKind, overviewMetricOptions, overviewWidgetCollisionDetection, OverviewWidgetConfig, OverviewWidgetSize, overviewWidgetSizeOptions, OverviewWidgetType, OverviewWidgetVariant, Pencil, Pie, PieChart, Plus, PointerSensor, primaryProviderAccountMeter, providerAccountMeterDetailValidityProgress, providerAccountMeterProgress, providerAccountMetersForDisplay, providerAccountProgressClass, isProviderAccountManualResetMeter, providerAccountSnapshotKey, providerAccountSnapshotLabel, ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ReactPointerEvent, rectSortingStrategy, RefreshCw, Select, - SelectControl, SortableContext, sortableKeyboardCoordinates, systemStatusIconClass, systemStatusPointTooltip, systemStatusSegmentClass, + SelectControl, SortableContext, sortableKeyboardCoordinates, systemStatusPointTooltip, systemStatusTooltipPositionClass, Tooltip, translateOptions, Trash2, UsageComparisonRow, usageRangeOptions, GatewayProviderConfig, UsageSeriesPoint, UsageStatsRange, UsageStatsSnapshot, usageStatusTone, UsageTotals, useAppText, useEffect, useMemo, useRef, useSensor, useSensors, useSortable, @@ -24,7 +24,10 @@ import { } from "../shared/index"; import { buildTokenActivity, type TokenActivityCell } from "@/lib/usage-activity"; import { ShareCardWidget } from "./share-cards"; -import { Cloud, Rocket } from "lucide-react"; +import { + CalendarDays, ChartNoAxesCombined, ChartPie, Cloud, GripHorizontal, Inbox, Layers3, + Rocket, Server, SlidersHorizontal, UsersRound, WalletCards +} from "lucide-react"; type OverviewUsageFilters = { modelFilter: string; @@ -305,9 +308,7 @@ export function OverviewView({ ))} {visibleWidgets.length === 0 ? ( -
- {t("No widgets configured")} -
+ ) : null} @@ -330,24 +331,28 @@ export function OverviewView({ return ( -
+
+ -