From c1b35e08dda45029d300e73d7f8099aeef175b03 Mon Sep 17 00:00:00 2001 From: zzzia Date: Tue, 24 Feb 2026 22:02:38 +0800 Subject: [PATCH 1/2] feat: add support for show multi model usage --- plugins/antigravity/plugin.json | 23 ++- plugins/codex/plugin.json | 69 +++++-- src/App.test.tsx | 83 +++++---- src/App.tsx | 14 +- src/components/app/app-content.tsx | 9 + src/hooks/app/use-settings-bootstrap.ts | 12 ++ src/hooks/app/use-settings-plugin-actions.ts | 46 +++++ .../app/use-settings-plugin-list.test.ts | 4 +- src/hooks/app/use-settings-plugin-list.ts | 4 + src/hooks/app/use-settings-system-actions.ts | 15 ++ src/hooks/app/use-tray-icon.ts | 107 +++++++---- src/lib/settings.test.ts | 19 +- src/lib/settings.ts | 41 +++- src/lib/tray-bars-icon.test.ts | 32 +++- src/lib/tray-bars-icon.ts | 175 ++++++++++++------ src/lib/tray-primary-progress.test.ts | 16 +- src/lib/tray-primary-progress.ts | 37 +++- src/pages/settings.test.tsx | 13 +- src/pages/settings.tsx | 52 +++++- src/stores/app-preferences-store.ts | 5 + 20 files changed, 601 insertions(+), 175 deletions(-) diff --git a/plugins/antigravity/plugin.json b/plugins/antigravity/plugin.json index 51927ba3..0a0589e1 100644 --- a/plugins/antigravity/plugin.json +++ b/plugins/antigravity/plugin.json @@ -7,8 +7,23 @@ "icon": "icon.svg", "brandColor": "#4285F4", "lines": [ - { "type": "progress", "label": "Gemini Pro", "scope": "overview", "primaryOrder": 1 }, - { "type": "progress", "label": "Gemini Flash", "scope": "overview" }, - { "type": "progress", "label": "Claude", "scope": "overview" } + { + "type": "progress", + "label": "Gemini Pro", + "scope": "overview", + "primaryOrder": 1 + }, + { + "type": "progress", + "label": "Gemini Flash", + "scope": "overview", + "primaryOrder": 2 + }, + { + "type": "progress", + "label": "Claude", + "scope": "overview", + "primaryOrder": 3 + } ] -} +} \ No newline at end of file diff --git a/plugins/codex/plugin.json b/plugins/codex/plugin.json index 2d58466b..8ee9a9bc 100644 --- a/plugins/codex/plugin.json +++ b/plugins/codex/plugin.json @@ -7,18 +7,63 @@ "icon": "icon.svg", "brandColor": "#74AA9C", "links": [ - { "label": "Status", "url": "https://status.openai.com/" }, - { "label": "Usage dashboard", "url": "https://platform.openai.com/usage" } + { + "label": "Status", + "url": "https://status.openai.com/" + }, + { + "label": "Usage dashboard", + "url": "https://platform.openai.com/usage" + } ], "lines": [ - { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 }, - { "type": "progress", "label": "Weekly", "scope": "overview" }, - { "type": "progress", "label": "Spark", "scope": "detail" }, - { "type": "progress", "label": "Spark Weekly", "scope": "detail" }, - { "type": "progress", "label": "Reviews", "scope": "detail" }, - { "type": "progress", "label": "Credits", "scope": "detail" }, - { "type": "text", "label": "Today", "scope": "detail" }, - { "type": "text", "label": "Yesterday", "scope": "detail" }, - { "type": "text", "label": "Last 30 Days", "scope": "detail" } + { + "type": "progress", + "label": "Session", + "scope": "overview", + "primaryOrder": 1 + }, + { + "type": "progress", + "label": "Weekly", + "scope": "overview", + "primaryOrder": 2 + }, + { + "type": "progress", + "label": "Spark", + "scope": "detail" + }, + { + "type": "progress", + "label": "Spark Weekly", + "scope": "detail" + }, + { + "type": "progress", + "label": "Reviews", + "scope": "detail", + "primaryOrder": 3 + }, + { + "type": "progress", + "label": "Credits", + "scope": "detail" + }, + { + "type": "text", + "label": "Today", + "scope": "detail" + }, + { + "type": "text", + "label": "Yesterday", + "scope": "detail" + }, + { + "type": "text", + "label": "Last 30 Days", + "scope": "detail" + } ] -} +} \ No newline at end of file diff --git a/src/App.test.tsx b/src/App.test.tsx index 4fe36e2b..c52fee65 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -74,8 +74,8 @@ vi.mock("@dnd-kit/core", () => ({ return
{children}
}, closestCenter: vi.fn(), - PointerSensor: class {}, - KeyboardSensor: class {}, + PointerSensor: class { }, + KeyboardSensor: class { }, useSensor: vi.fn((_sensor: any, options?: any) => ({ sensor: _sensor, options })), useSensors: vi.fn((...sensors: any[]) => sensors), })) @@ -481,7 +481,7 @@ describe("App", () => { await waitFor(() => expect(state.renderTrayBarsIconMock).toHaveBeenCalled()) const firstCall = state.renderTrayBarsIconMock.mock.calls[0]?.[0] expect(firstCall.providerIconUrl).toBe("icon-a") - await waitFor(() => expect(state.traySetTitleMock).toHaveBeenCalledWith("--%")) + await waitFor(() => expect(state.traySetTitleMock).toHaveBeenCalledWith(null)) }) it("renders percent text in tray icon when native title is unavailable", async () => { @@ -495,7 +495,7 @@ describe("App", () => { await waitFor(() => expect(state.renderTrayBarsIconMock).toHaveBeenCalled()) const firstCall = state.renderTrayBarsIconMock.mock.calls[0]?.[0] - expect(firstCall.percentText).toBe("--%") + expect(firstCall.gridCells).toEqual([]) expect(state.traySetTitleMock).not.toHaveBeenCalled() }) @@ -546,14 +546,14 @@ describe("App", () => { const latestCall = state.renderTrayBarsIconMock.mock.calls.at(-1)?.[0] expect(latestCall.providerIconUrl).toBe("icon-b") }) - await waitFor(() => expect(state.traySetTitleMock).toHaveBeenCalledWith("70%")) + await waitFor(() => expect(state.traySetTitleMock).toHaveBeenCalledWith(null)) await userEvent.click(screen.getByRole("button", { name: "Home" })) await waitFor(() => { const latestCall = state.renderTrayBarsIconMock.mock.calls.at(-1)?.[0] expect(latestCall.providerIconUrl).toBe("icon-b") }) - await waitFor(() => expect(state.traySetTitleMock).toHaveBeenCalledWith("70%")) + await waitFor(() => expect(state.traySetTitleMock).toHaveBeenCalledWith(null)) const settingsButtons = await screen.findAllByRole("button", { name: "Settings" }) await userEvent.click(settingsButtons[0]) @@ -561,7 +561,7 @@ describe("App", () => { const latestCall = state.renderTrayBarsIconMock.mock.calls.at(-1)?.[0] expect(latestCall.providerIconUrl).toBe("icon-b") }) - await waitFor(() => expect(state.traySetTitleMock).toHaveBeenCalledWith("70%")) + await waitFor(() => expect(state.traySetTitleMock).toHaveBeenCalledWith(null)) }) it("covers about open/close callbacks", async () => { @@ -588,7 +588,7 @@ describe("App", () => { }) it("logs when saving display mode fails", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.saveDisplayModeMock.mockRejectedValueOnce(new Error("save display mode")) render() @@ -653,7 +653,7 @@ describe("App", () => { }) it("logs when saving auto-update interval fails", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.saveAutoUpdateIntervalMock.mockRejectedValueOnce(new Error("save interval")) render() const settingsButtons = await screen.findAllByRole("button", { name: "Settings" }) @@ -684,7 +684,7 @@ describe("App", () => { }) it("logs when saving start on login fails", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.saveStartOnLoginMock.mockRejectedValueOnce(new Error("save start on login failed")) render() @@ -699,7 +699,7 @@ describe("App", () => { }) it("logs when applying start on login setting fails on startup", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.isTauriMock.mockReturnValue(true) state.loadStartOnLoginMock.mockResolvedValueOnce(true) state.autostartIsEnabledMock.mockRejectedValueOnce(new Error("autostart status failed")) @@ -713,7 +713,7 @@ describe("App", () => { }) it("logs when updating start on login fails from settings", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.isTauriMock.mockReturnValue(true) state.loadStartOnLoginMock.mockResolvedValueOnce(false) state.autostartIsEnabledMock @@ -732,7 +732,7 @@ describe("App", () => { }) it("logs when loading display mode fails", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.loadDisplayModeMock.mockRejectedValueOnce(new Error("load display mode")) render() @@ -743,7 +743,7 @@ describe("App", () => { }) it("logs when migrating legacy tray settings fails", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.migrateLegacyTraySettingsMock.mockRejectedValueOnce(new Error("migrate legacy tray")) render() @@ -754,7 +754,7 @@ describe("App", () => { }) it("logs when saving theme mode fails", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.saveThemeModeMock.mockRejectedValueOnce(new Error("save theme")) render() const settingsButtons = await screen.findAllByRole("button", { name: "Settings" }) @@ -885,7 +885,7 @@ describe("App", () => { removeAction() await waitFor(() => - expect(state.savePluginSettingsMock).toHaveBeenCalledWith({ order: ["a", "b"], disabled: ["b"] }) + expect(state.savePluginSettingsMock).toHaveBeenCalledWith({ order: ["a", "b"], disabled: ["b"], trayLines: {} }) ) expect(state.trackMock).toHaveBeenCalledWith("provider_toggled", { provider_id: "b", enabled: "false" }) expect(state.startBatchMock).not.toHaveBeenCalled() @@ -901,7 +901,7 @@ describe("App", () => { const removeAction = await triggerPluginContextAction("Beta", "b", "remove") removeAction() await waitFor(() => - expect(state.savePluginSettingsMock).toHaveBeenCalledWith({ order: ["a", "b"], disabled: ["b"] }) + expect(state.savePluginSettingsMock).toHaveBeenCalledWith({ order: ["a", "b"], disabled: ["b"], trayLines: {} }) ) await waitFor(() => expect(screen.queryByRole("button", { name: "Beta" })).not.toBeInTheDocument() @@ -941,7 +941,7 @@ describe("App", () => { }) it("handles plugin list load failure", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.invokeMock.mockImplementation(async (cmd: string) => { if (cmd === "list_plugins") { throw new Error("boom") @@ -954,7 +954,7 @@ describe("App", () => { }) it("handles initial batch failure", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.startBatchMock.mockRejectedValueOnce(new Error("fail")) render() const errors = await screen.findAllByText("Failed to start probe") @@ -964,7 +964,7 @@ describe("App", () => { it("handles enable toggle failures", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["a", "b"], disabled: ["b"] }) state.startBatchMock .mockResolvedValueOnce(["a"]) @@ -1012,8 +1012,8 @@ describe("App", () => { observeSpy() this.cb([], this as unknown as ResizeObserver) } - unobserve() {} - disconnect() {} + unobserve() { } + disconnect() { } } as unknown as typeof ResizeObserver render() @@ -1025,7 +1025,7 @@ describe("App", () => { it("logs resize failures", async () => { state.isTauriMock.mockReturnValue(true) - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.setSizeMock.mockRejectedValueOnce(new Error("size fail")) render() await waitFor(() => expect(errorSpy).toHaveBeenCalled()) @@ -1035,7 +1035,7 @@ describe("App", () => { it("logs when saving plugin order fails", async () => { state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["a", "b"], disabled: [] }) state.savePluginSettingsMock.mockRejectedValueOnce(new Error("save order")) - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) render() const settingsButtons = await screen.findAllByRole("button", { name: "Settings" }) await userEvent.click(settingsButtons[0]) @@ -1087,7 +1087,7 @@ describe("App", () => { }) it("logs when tray handle cannot be loaded", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.trayGetByIdMock.mockRejectedValueOnce(new Error("no tray")) render() await waitFor(() => expect(errorSpy).toHaveBeenCalled()) @@ -1095,7 +1095,7 @@ describe("App", () => { }) it("logs when tray gauge resource cannot be resolved", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.resolveResourceMock.mockRejectedValueOnce(new Error("no resource")) render() await waitFor(() => expect(errorSpy).toHaveBeenCalled()) @@ -1103,7 +1103,7 @@ describe("App", () => { }) it("logs error when retry plugin batch fails", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) render() await waitFor(() => expect(state.startBatchMock).toHaveBeenCalled()) @@ -1184,7 +1184,7 @@ describe("App", () => { it("logs error when auto-update batch fails", async () => { vi.useFakeTimers() - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.loadAutoUpdateIntervalMock.mockResolvedValueOnce(5) state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["a"], disabled: [] }) @@ -1210,7 +1210,7 @@ describe("App", () => { }) it("logs error when loading auto-update interval fails", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.loadAutoUpdateIntervalMock.mockRejectedValueOnce(new Error("load interval failed")) render() await waitFor(() => @@ -1220,7 +1220,7 @@ describe("App", () => { }) it("logs error when loading theme mode fails", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.loadThemeModeMock.mockRejectedValueOnce(new Error("load theme failed")) render() await waitFor(() => @@ -1230,7 +1230,7 @@ describe("App", () => { }) it("logs error when loading start on login fails", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.loadStartOnLoginMock.mockRejectedValueOnce(new Error("load start on login failed")) render() await waitFor(() => @@ -1287,7 +1287,7 @@ describe("App", () => { await waitFor(() => expect(screen.getAllByRole("button", { name: "Retry" })).toHaveLength(2)) const initialCalls = state.startBatchMock.mock.calls.length - state.startBatchMock.mockImplementation(() => new Promise(() => {})) + state.startBatchMock.mockImplementation(() => new Promise(() => { })) const refreshButton = await screen.findByRole("button", { name: /Next update in/i }) await userEvent.click(refreshButton) @@ -1302,7 +1302,7 @@ describe("App", () => { }) it("does not leak manual refresh cooldown state when refresh-all start fails", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) try { state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["a"], disabled: [] }) render() @@ -1458,7 +1458,7 @@ describe("App", () => { }) it("logs error when loading global shortcut fails", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) state.loadGlobalShortcutMock.mockRejectedValueOnce(new Error("load shortcut failed")) render() @@ -1470,7 +1470,7 @@ describe("App", () => { }) it("logs error when saving global shortcut fails", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) // Start with a shortcut so we can clear it state.loadGlobalShortcutMock.mockResolvedValueOnce("CommandOrControl+Shift+U") state.saveGlobalShortcutMock.mockRejectedValueOnce(new Error("save shortcut failed")) @@ -1491,7 +1491,7 @@ describe("App", () => { }) it("logs error when update_global_shortcut invoke fails", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { }) // Start with a shortcut so we can clear it state.loadGlobalShortcutMock.mockResolvedValueOnce("CommandOrControl+Shift+U") state.invokeMock.mockImplementation(async (cmd: string) => { @@ -1552,7 +1552,14 @@ describe("App", () => { render() await vi.waitFor(() => expect(state.startBatchMock).toHaveBeenCalled()) await vi.waitFor(() => expect(state.trayGetByIdMock).toHaveBeenCalled()) - await vi.waitFor(() => expect(state.renderTrayBarsIconMock).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => + expect(state.renderTrayBarsIconMock).toHaveBeenCalledWith({ + sizePx: expect.any(Number), + gridCells: [], + providerIconUrl: "icon-a", + hideIcon: false, + }) + ) state.probeHandlers?.onResult({ providerId: "a", @@ -1586,7 +1593,7 @@ describe("App", () => { await waitFor(() => expect(state.traySetIconMock).toHaveBeenCalledWith({})) expect(state.traySetIconAsTemplateMock).toHaveBeenCalledWith(true) - expect(state.traySetTitleMock).toHaveBeenCalledWith("--%") + expect(state.traySetTitleMock).toHaveBeenCalledWith(null) }) it("clears pending tray timer on unmount", async () => { diff --git a/src/App.tsx b/src/App.tsx index ccd63fcd..510a1b74 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -56,6 +56,8 @@ function App() { setResetTimerDisplayMode, setGlobalShortcut, setStartOnLogin, + showTrayIcon, + setShowTrayIcon, } = useAppPreferencesStore( useShallow((state) => ({ autoUpdateInterval: state.autoUpdateInterval, @@ -68,10 +70,12 @@ function App() { setResetTimerDisplayMode: state.setResetTimerDisplayMode, setGlobalShortcut: state.setGlobalShortcut, setStartOnLogin: state.setStartOnLogin, + showTrayIcon: state.showTrayIcon, + setShowTrayIcon: state.setShowTrayIcon, })) ) - const scheduleProbeTrayUpdateRef = useRef<() => void>(() => {}) + const scheduleProbeTrayUpdateRef = useRef<() => void>(() => { }) const handleProbeResult = useCallback(() => { scheduleProbeTrayUpdateRef.current() }, []) @@ -97,6 +101,7 @@ function App() { pluginStates, displayMode, activeView, + showTrayIcon, }) useEffect(() => { @@ -114,6 +119,7 @@ function App() { setResetTimerDisplayMode, setGlobalShortcut, setStartOnLogin, + setShowTrayIcon, setLoadingForPlugins, setErrorForPlugins, startBatch, @@ -138,18 +144,22 @@ function App() { handleAutoUpdateIntervalChange, handleGlobalShortcutChange, handleStartOnLoginChange, + handleShowTrayIconChange, } = useSettingsSystemActions({ pluginSettings, setAutoUpdateInterval, setAutoUpdateNextAt, setGlobalShortcut, setStartOnLogin, + setShowTrayIcon, applyStartOnLogin, + scheduleTrayIconUpdate, }) const { handleReorder, handleToggle, + handleTrayLineToggle, } = useSettingsPluginActions({ pluginSettings, setPluginSettings, @@ -232,6 +242,7 @@ function App() { onRetryPlugin: handleRetryPlugin, onReorder: handleReorder, onToggle: handleToggle, + onTrayLineToggle: handleTrayLineToggle, onAutoUpdateIntervalChange: handleAutoUpdateIntervalChange, onThemeModeChange: handleThemeModeChange, onDisplayModeChange: handleDisplayModeChange, @@ -239,6 +250,7 @@ function App() { onResetTimerDisplayModeToggle: handleResetTimerDisplayModeToggle, onGlobalShortcutChange: handleGlobalShortcutChange, onStartOnLoginChange: handleStartOnLoginChange, + onShowTrayIconChange: handleShowTrayIconChange, }} /> ) diff --git a/src/components/app/app-content.tsx b/src/components/app/app-content.tsx index 5ecda263..b141ca19 100644 --- a/src/components/app/app-content.tsx +++ b/src/components/app/app-content.tsx @@ -24,6 +24,7 @@ export type AppContentActionProps = { onRetryPlugin: (id: string) => void onReorder: (orderedIds: string[]) => void onToggle: (id: string) => void + onTrayLineToggle: (id: string, lineLabel: string, checked: boolean, fallback?: string) => void onAutoUpdateIntervalChange: (value: AutoUpdateIntervalMinutes) => void onThemeModeChange: (mode: ThemeMode) => void onDisplayModeChange: (mode: DisplayMode) => void @@ -31,6 +32,7 @@ export type AppContentActionProps = { onResetTimerDisplayModeToggle: () => void onGlobalShortcutChange: (value: GlobalShortcut) => void onStartOnLoginChange: (value: boolean) => void + onShowTrayIconChange: (value: boolean) => void } export type AppContentProps = AppContentDerivedProps & AppContentActionProps @@ -42,6 +44,7 @@ export function AppContent({ onRetryPlugin, onReorder, onToggle, + onTrayLineToggle, onAutoUpdateIntervalChange, onThemeModeChange, onDisplayModeChange, @@ -49,6 +52,7 @@ export function AppContent({ onResetTimerDisplayModeToggle, onGlobalShortcutChange, onStartOnLoginChange, + onShowTrayIconChange, }: AppContentProps) { const { activeView } = useAppUiStore( useShallow((state) => ({ @@ -63,6 +67,7 @@ export function AppContent({ globalShortcut, themeMode, startOnLogin, + showTrayIcon, } = useAppPreferencesStore( useShallow((state) => ({ displayMode: state.displayMode, @@ -71,6 +76,7 @@ export function AppContent({ globalShortcut: state.globalShortcut, themeMode: state.themeMode, startOnLogin: state.startOnLogin, + showTrayIcon: state.showTrayIcon, })) ) @@ -92,6 +98,7 @@ export function AppContent({ plugins={settingsPlugins} onReorder={onReorder} onToggle={onToggle} + onTrayLineToggle={onTrayLineToggle} autoUpdateInterval={autoUpdateInterval} onAutoUpdateIntervalChange={onAutoUpdateIntervalChange} themeMode={themeMode} @@ -104,6 +111,8 @@ export function AppContent({ onGlobalShortcutChange={onGlobalShortcutChange} startOnLogin={startOnLogin} onStartOnLoginChange={onStartOnLoginChange} + showTrayIcon={showTrayIcon} + onShowTrayIconChange={onShowTrayIconChange} /> ) } diff --git a/src/hooks/app/use-settings-bootstrap.ts b/src/hooks/app/use-settings-bootstrap.ts index 6c6d6598..926bd0ca 100644 --- a/src/hooks/app/use-settings-bootstrap.ts +++ b/src/hooks/app/use-settings-bootstrap.ts @@ -21,6 +21,7 @@ import { migrateLegacyTraySettings, loadPluginSettings, loadResetTimerDisplayMode, + loadShowTrayIcon, loadStartOnLogin, loadThemeMode, normalizePluginSettings, @@ -42,6 +43,7 @@ type UseSettingsBootstrapArgs = { setResetTimerDisplayMode: (value: ResetTimerDisplayMode) => void setGlobalShortcut: (value: GlobalShortcut) => void setStartOnLogin: (value: boolean) => void + setShowTrayIcon: (value: boolean) => void setLoadingForPlugins: (ids: string[]) => void setErrorForPlugins: (ids: string[], error: string) => void startBatch: (pluginIds?: string[]) => Promise @@ -56,6 +58,7 @@ export function useSettingsBootstrap({ setResetTimerDisplayMode, setGlobalShortcut, setStartOnLogin, + setShowTrayIcon, setLoadingForPlugins, setErrorForPlugins, startBatch, @@ -130,6 +133,13 @@ export function useSettingsBootstrap({ console.error("Failed to load start on login:", error) } + let storedShowTrayIcon = true + try { + storedShowTrayIcon = await loadShowTrayIcon() + } catch (error) { + console.error("Failed to load show tray icon:", error) + } + try { await applyStartOnLogin(storedStartOnLogin) } catch (error) { @@ -149,6 +159,7 @@ export function useSettingsBootstrap({ setResetTimerDisplayMode(storedResetTimerDisplayMode) setGlobalShortcut(storedGlobalShortcut) setStartOnLogin(storedStartOnLogin) + setShowTrayIcon(storedShowTrayIcon) const enabledIds = getEnabledPluginIds(normalized) setLoadingForPlugins(enabledIds) @@ -182,6 +193,7 @@ export function useSettingsBootstrap({ setPluginSettings, setPluginsMeta, setResetTimerDisplayMode, + setShowTrayIcon, setStartOnLogin, setThemeMode, startBatch, diff --git a/src/hooks/app/use-settings-plugin-actions.ts b/src/hooks/app/use-settings-plugin-actions.ts index 44de8a61..e5e6ea3c 100644 --- a/src/hooks/app/use-settings-plugin-actions.ts +++ b/src/hooks/app/use-settings-plugin-actions.ts @@ -72,8 +72,54 @@ export function useSettingsPluginActions({ startBatch, ]) + const handleTrayLineToggle = useCallback((id: string, lineLabel: string, checked: boolean, fallback?: string) => { + if (!pluginSettings) return + const prevTrayLines = pluginSettings.trayLines || {} + let currentLinesForPlugin = prevTrayLines[id] || [] + + if (currentLinesForPlugin.length === 0 && fallback) { + currentLinesForPlugin = [fallback] + } + + let nextLinesForPlugin: string[] + if (checked) { + if (!currentLinesForPlugin.includes(lineLabel)) { + nextLinesForPlugin = [...currentLinesForPlugin, lineLabel] + } else { + nextLinesForPlugin = currentLinesForPlugin + } + } else { + nextLinesForPlugin = currentLinesForPlugin.filter(l => l !== lineLabel) + } + + const nextTrayLines = { + ...prevTrayLines, + [id]: nextLinesForPlugin, + } + + // Clean up empty arrays to keep state minimal + if (nextLinesForPlugin.length === 0) { + delete nextTrayLines[id] + } + + const nextSettings: PluginSettings = { + ...pluginSettings, + trayLines: nextTrayLines, + } + setPluginSettings(nextSettings) + scheduleTrayIconUpdate("settings", TRAY_SETTINGS_DEBOUNCE_MS) + void savePluginSettings(nextSettings).catch((error) => { + console.error("Failed to save tray line toggle:", error) + }) + }, [ + pluginSettings, + scheduleTrayIconUpdate, + setPluginSettings, + ]) + return { handleReorder, handleToggle, + handleTrayLineToggle, } } diff --git a/src/hooks/app/use-settings-plugin-list.test.ts b/src/hooks/app/use-settings-plugin-list.test.ts index 8f7b32ee..fa068674 100644 --- a/src/hooks/app/use-settings-plugin-list.test.ts +++ b/src/hooks/app/use-settings-plugin-list.test.ts @@ -33,8 +33,8 @@ describe("useSettingsPluginList", () => { ) expect(result.current).toEqual([ - { id: "codex", name: "Codex", enabled: true }, - { id: "cursor", name: "Cursor", enabled: false }, + { id: "codex", name: "Codex", enabled: true, primaryCandidates: [], trayLines: [] }, + { id: "cursor", name: "Cursor", enabled: false, primaryCandidates: [], trayLines: [] }, ]) }) diff --git a/src/hooks/app/use-settings-plugin-list.ts b/src/hooks/app/use-settings-plugin-list.ts index 618c72a0..5824d742 100644 --- a/src/hooks/app/use-settings-plugin-list.ts +++ b/src/hooks/app/use-settings-plugin-list.ts @@ -6,6 +6,8 @@ export type SettingsPluginState = { id: string name: string enabled: boolean + primaryCandidates: string[] + trayLines: string[] } type UseSettingsPluginListArgs = { @@ -26,6 +28,8 @@ export function useSettingsPluginList({ pluginSettings, pluginsMeta }: UseSettin id, name: meta.name, enabled: !pluginSettings.disabled.includes(id), + primaryCandidates: meta.primaryCandidates || [], + trayLines: pluginSettings.trayLines?.[id] || [], } }) .filter((plugin): plugin is SettingsPluginState => Boolean(plugin)) diff --git a/src/hooks/app/use-settings-system-actions.ts b/src/hooks/app/use-settings-system-actions.ts index fd99f11b..a1e4a72b 100644 --- a/src/hooks/app/use-settings-system-actions.ts +++ b/src/hooks/app/use-settings-system-actions.ts @@ -6,6 +6,7 @@ import { saveAutoUpdateInterval, saveGlobalShortcut, saveStartOnLogin, + saveShowTrayIcon, type AutoUpdateIntervalMinutes, type GlobalShortcut, type PluginSettings, @@ -17,7 +18,9 @@ type UseSettingsSystemActionsArgs = { setAutoUpdateNextAt: (value: number | null) => void setGlobalShortcut: (value: GlobalShortcut) => void setStartOnLogin: (value: boolean) => void + setShowTrayIcon: (value: boolean) => void applyStartOnLogin: (value: boolean) => Promise + scheduleTrayIconUpdate: (source: "settings", delay: number) => void } export function useSettingsSystemActions({ @@ -26,7 +29,9 @@ export function useSettingsSystemActions({ setAutoUpdateNextAt, setGlobalShortcut, setStartOnLogin, + setShowTrayIcon, applyStartOnLogin, + scheduleTrayIconUpdate, }: UseSettingsSystemActionsArgs) { const handleAutoUpdateIntervalChange = useCallback((value: AutoUpdateIntervalMinutes) => { track("setting_changed", { setting: "auto_refresh", value: String(value) }) @@ -68,9 +73,19 @@ export function useSettingsSystemActions({ }) }, [applyStartOnLogin, setStartOnLogin]) + const handleShowTrayIconChange = useCallback((value: boolean) => { + track("setting_changed", { setting: "show_tray_icon", value: value ? "true" : "false" }) + setShowTrayIcon(value) + void saveShowTrayIcon(value).catch((error) => { + console.error("Failed to save show tray icon:", error) + }) + scheduleTrayIconUpdate("settings", 200) + }, [setShowTrayIcon, scheduleTrayIconUpdate]) + return { handleAutoUpdateIntervalChange, handleGlobalShortcutChange, handleStartOnLoginChange, + handleShowTrayIconChange, } } diff --git a/src/hooks/app/use-tray-icon.ts b/src/hooks/app/use-tray-icon.ts index 2216a969..bebc3f1b 100644 --- a/src/hooks/app/use-tray-icon.ts +++ b/src/hooks/app/use-tray-icon.ts @@ -4,7 +4,7 @@ import { TrayIcon } from "@tauri-apps/api/tray" import type { PluginMeta } from "@/lib/plugin-types" import type { DisplayMode, PluginSettings } from "@/lib/settings" import { getEnabledPluginIds } from "@/lib/settings" -import { getTrayIconSizePx, renderTrayBarsIcon } from "@/lib/tray-bars-icon" +import { getTrayIconSizePx, renderTrayBarsIcon, type TrayGridCell } from "@/lib/tray-bars-icon" import { getTrayPrimaryBars } from "@/lib/tray-primary-progress" import type { PluginState } from "@/hooks/app/types" @@ -16,6 +16,7 @@ type UseTrayIconArgs = { pluginStates: Record displayMode: DisplayMode activeView: string + showTrayIcon: boolean } export function useTrayIcon({ @@ -24,6 +25,7 @@ export function useTrayIcon({ pluginStates, displayMode, activeView, + showTrayIcon, }: UseTrayIconArgs) { const trayRef = useRef(null) const trayGaugeIconPathRef = useRef(null) @@ -37,6 +39,7 @@ export function useTrayIcon({ const pluginStatesRef = useRef(pluginStates) const displayModeRef = useRef(displayMode) const activeViewRef = useRef(activeView) + const showTrayIconRef = useRef(showTrayIcon) const lastTrayProviderIdRef = useRef(null) useEffect(() => { @@ -59,6 +62,10 @@ export function useTrayIcon({ activeViewRef.current = activeView }, [activeView]) + useEffect(() => { + showTrayIconRef.current = showTrayIcon + }, [showTrayIcon]) + const scheduleTrayIconUpdate = useCallback(( _reason: TrayUpdateReason, delayMs = 0, @@ -89,11 +96,13 @@ export function useTrayIcon({ return } - const maybeSetTitle = (tray as TrayIcon & { setTitle?: (value: string) => Promise }).setTitle + const maybeSetTitle = + (tray as TrayIcon & { setTitle?: (value: string | null) => Promise }).setTitle const setTitleFn = - typeof maybeSetTitle === "function" ? (value: string) => maybeSetTitle.call(tray, value) : null - const supportsNativeTrayTitle = setTitleFn !== null - const setTrayTitle = (title: string) => { + typeof maybeSetTitle === "function" + ? (value: string | null) => maybeSetTitle.call(tray, value) + : null + const setTrayTitle = (title: string | null) => { if (setTitleFn) { return setTitleFn(title) } @@ -162,25 +171,61 @@ export function useTrayIcon({ pluginId: trayProviderId, }) - const fraction = bars[0]?.fraction - const hasFraction = typeof fraction === "number" && Number.isFinite(fraction) - const clampedFraction = hasFraction ? Math.max(0, Math.min(1, fraction)) : undefined - const percentText = - typeof clampedFraction === "number" ? `${Math.round(clampedFraction * 100)}%` : "--%" + const items = bars[0]?.items || [] + + let tooltipText: string | undefined + let gridCellsToRender: TrayGridCell[] = [] + let providerIconUrlToRender = pluginsMetaRef.current.find((plugin) => plugin.id === trayProviderId)?.iconUrl + + if (items.length > 0) { + tooltipText = items.map(item => { + const hasFraction = typeof item.fraction === "number" && Number.isFinite(item.fraction) + const clampedFraction = hasFraction ? Math.max(0, Math.min(1, item.fraction!)) : undefined + return `${item.label}: ${typeof clampedFraction === "number" ? Math.round(clampedFraction * 100) + '%' : '--%'}` + }).join("\n") + + gridCellsToRender = items.map(item => { + const hasFraction = typeof item.fraction === "number" && Number.isFinite(item.fraction) + const clampedFraction = hasFraction ? Math.max(0, Math.min(1, item.fraction!)) : undefined + const valStr = typeof clampedFraction === "number" ? `${Math.round(clampedFraction * 100)}%` : "--%" + + if (items.length === 1) { + return { text: valStr } + } + + let shortLabel = item.label + const words = shortLabel.split(" ") + if (words.length > 1) { + shortLabel = words[words.length - 1] // e.g. "Gemini Flash" -> "Flash" + } + if (shortLabel.length > 5) { + shortLabel = shortLabel.substring(0, 3) // e.g. "Session" -> "Ses", "Weekly" -> "Wee" + } + // The user specifically requested a space here ("加一个空格以美化展示效果") + return { text: `${shortLabel} ${valStr}` } + }) + } const sizePx = getTrayIconSizePx(window.devicePixelRatio) - const providerIconUrl = - pluginsMetaRef.current.find((plugin) => plugin.id === trayProviderId)?.iconUrl renderTrayBarsIcon({ sizePx, - percentText: supportsNativeTrayTitle ? undefined : percentText, - providerIconUrl, + gridCells: gridCellsToRender, + providerIconUrl: showTrayIconRef.current ? providerIconUrlToRender : undefined, + hideIcon: !showTrayIconRef.current, }) .then(async (img) => { await tray.setIcon(img) await tray.setIconAsTemplate(true) - await setTrayTitle(percentText) + await setTrayTitle(null) // Disabling native Title clipping entirely + const maybeSetTooltip = + (tray as TrayIcon & { setTooltip?: (value: string | null) => Promise }).setTooltip + if (typeof maybeSetTooltip === "function") { + // If tooltip is null, clear current tooltip. + await maybeSetTooltip.call(tray, tooltipText ?? null).catch((error) => { + console.error("Failed to update tray tooltip:", error) + }) + } }) .catch((e) => { console.error("Failed to update tray icon:", e) @@ -196,25 +241,25 @@ export function useTrayIcon({ if (trayInitializedRef.current) return let cancelled = false - ;(async () => { - try { - const tray = await TrayIcon.getById("tray") - if (cancelled) return - trayRef.current = tray - trayInitializedRef.current = true - + ; (async () => { try { - trayGaugeIconPathRef.current = await resolveResource("icons/tray-icon.png") + const tray = await TrayIcon.getById("tray") + if (cancelled) return + trayRef.current = tray + trayInitializedRef.current = true + + try { + trayGaugeIconPathRef.current = await resolveResource("icons/tray-icon.png") + } catch (e) { + console.error("Failed to resolve tray gauge icon resource:", e) + } + + if (cancelled) return + setTrayReady(true) } catch (e) { - console.error("Failed to resolve tray gauge icon resource:", e) + console.error("Failed to load tray icon handle:", e) } - - if (cancelled) return - setTrayReady(true) - } catch (e) { - console.error("Failed to load tray icon handle:", e) - } - })() + })() return () => { cancelled = true diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 3ea30127..313c0cad 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -64,25 +64,26 @@ describe("settings", () => { await expect(loadPluginSettings()).resolves.toEqual({ order: ["a"], disabled: [], + trayLines: {}, }) }) it("saves settings", async () => { - const settings = { order: ["a"], disabled: ["b"] } + const settings = { order: ["a"], disabled: ["b"], trayLines: { a: ["Session"] } } await savePluginSettings(settings) await expect(loadPluginSettings()).resolves.toEqual(settings) }) it("normalizes order + disabled against known plugins", () => { const plugins: PluginMeta[] = [ - { id: "a", name: "A", iconUrl: "", lines: [] }, - { id: "b", name: "B", iconUrl: "", lines: [] }, + { id: "a", name: "A", iconUrl: "", lines: [], primaryCandidates: [] }, + { id: "b", name: "B", iconUrl: "", lines: [], primaryCandidates: [] }, ] const normalized = normalizePluginSettings( - { order: ["b", "b", "c"], disabled: ["c", "a"] }, + { order: ["b", "b", "c"], disabled: ["c", "a"], trayLines: { "a": ["x"], "c": ["y"] } }, plugins ) - expect(normalized).toEqual({ order: ["b", "a"], disabled: ["a"] }) + expect(normalized).toEqual({ order: ["b", "a"], disabled: ["a"], trayLines: { "a": ["x"] } }) }) it("auto-disables new non-default plugins", () => { @@ -97,11 +98,13 @@ describe("settings", () => { }) it("compares settings equality", () => { - const a = { order: ["a"], disabled: [] } - const b = { order: ["a"], disabled: [] } - const c = { order: ["b"], disabled: [] } + const a = { order: ["a"], disabled: [], trayLines: {} } + const b = { order: ["a"], disabled: [], trayLines: {} } + const c = { order: ["b"], disabled: [], trayLines: {} } + const d = { order: ["a"], disabled: [], trayLines: { "a": ["x"] } } expect(arePluginSettingsEqual(a, b)).toBe(true) expect(arePluginSettingsEqual(a, c)).toBe(false) + expect(arePluginSettingsEqual(a, d)).toBe(false) }) it("returns enabled plugin ids", () => { diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 316aa430..a8fb1c05 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -8,6 +8,7 @@ export const REFRESH_COOLDOWN_MS = 300_000; export type PluginSettings = { order: string[]; disabled: string[]; + trayLines?: Record; }; export type AutoUpdateIntervalMinutes = 5 | 15 | 30 | 60; @@ -30,6 +31,7 @@ const LEGACY_TRAY_ICON_STYLE_KEY = "trayIconStyle"; const LEGACY_TRAY_SHOW_PERCENTAGE_KEY = "trayShowPercentage"; const GLOBAL_SHORTCUT_KEY = "globalShortcut"; const START_ON_LOGIN_KEY = "startOnLogin"; +const SHOW_TRAY_ICON_KEY = "showTrayIcon"; export const DEFAULT_AUTO_UPDATE_INTERVAL: AutoUpdateIntervalMinutes = 15; export const DEFAULT_THEME_MODE: ThemeMode = "system"; @@ -37,6 +39,7 @@ export const DEFAULT_DISPLAY_MODE: DisplayMode = "left"; export const DEFAULT_RESET_TIMER_DISPLAY_MODE: ResetTimerDisplayMode = "relative"; export const DEFAULT_GLOBAL_SHORTCUT: GlobalShortcut = null; export const DEFAULT_START_ON_LOGIN = false; +export const DEFAULT_SHOW_TRAY_ICON = true; const AUTO_UPDATE_INTERVALS: AutoUpdateIntervalMinutes[] = [5, 15, 30, 60]; const THEME_MODES: ThemeMode[] = ["system", "light", "dark"]; @@ -72,6 +75,7 @@ const DEFAULT_ENABLED_PLUGINS = new Set(["claude", "codex", "cursor"]); export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = { order: [], disabled: [], + trayLines: {}, }; export async function loadPluginSettings(): Promise { @@ -80,6 +84,7 @@ export async function loadPluginSettings(): Promise { return { order: Array.isArray(stored.order) ? stored.order : [], disabled: Array.isArray(stored.disabled) ? stored.disabled : [], + trayLines: stored.trayLines && typeof stored.trayLines === "object" ? stored.trayLines : {}, }; } @@ -137,7 +142,14 @@ export function normalizePluginSettings( disabled.push(id); } } - return { order, disabled }; + const trayLines = { ...settings.trayLines }; + for (const key in trayLines) { + if (!knownSet.has(key)) { + delete trayLines[key]; + } + } + + return { order, disabled, trayLines }; } export function arePluginSettingsEqual( @@ -152,6 +164,22 @@ export function arePluginSettingsEqual( for (let i = 0; i < a.disabled.length; i += 1) { if (a.disabled[i] !== b.disabled[i]) return false; } + + const aLines = a.trayLines || {}; + const bLines = b.trayLines || {}; + const aKeys = Object.keys(aLines); + const bKeys = Object.keys(bLines); + if (aKeys.length !== bKeys.length) return false; + + for (const key of aKeys) { + const aArr = aLines[key] || []; + const bArr = bLines[key] || []; + if (aArr.length !== bArr.length) return false; + for (let i = 0; i < aArr.length; i += 1) { + if (aArr[i] !== bArr[i]) return false; + } + } + return true; } @@ -265,3 +293,14 @@ export async function saveStartOnLogin(value: boolean): Promise { await store.set(START_ON_LOGIN_KEY, value); await store.save(); } + +export async function loadShowTrayIcon(): Promise { + const stored = await store.get(SHOW_TRAY_ICON_KEY); + if (typeof stored === "boolean") return stored; + return DEFAULT_SHOW_TRAY_ICON; +} + +export async function saveShowTrayIcon(value: boolean): Promise { + await store.set(SHOW_TRAY_ICON_KEY, value); + await store.save(); +} diff --git a/src/lib/tray-bars-icon.test.ts b/src/lib/tray-bars-icon.test.ts index c9acbff7..e1d8783a 100644 --- a/src/lib/tray-bars-icon.test.ts +++ b/src/lib/tray-bars-icon.test.ts @@ -46,14 +46,44 @@ describe("tray-bars-icon", () => { expect(svg).not.toContain("") }) + it("renders at most four grid cells", () => { + const svg = makeTrayBarsSvg({ + sizePx: 18, + gridCells: [ + { text: "A" }, + { text: "B" }, + { text: "C" }, + { text: "D" }, + { text: "E" }, + ], + }) + expect(svg).toContain(">A") + expect(svg).toContain(">D") + expect(svg).not.toContain(">E") + }) + it("renderTrayBarsIcon rasterizes SVG to an Image using canvas", async () => { const originalImage = window.Image const originalCreateElement = document.createElement.bind(document) diff --git a/src/lib/tray-bars-icon.ts b/src/lib/tray-bars-icon.ts index e54d2c89..e524d869 100644 --- a/src/lib/tray-bars-icon.ts +++ b/src/lib/tray-bars-icon.ts @@ -14,78 +14,143 @@ function escapeXmlText(text: string): string { .replace(/'/g, "'") } -function normalizePercentText(percentText: string | undefined): string | undefined { - if (typeof percentText !== "string") return undefined - const trimmed = percentText.trim() - return trimmed.length > 0 ? trimmed : undefined +export type TrayGridCell = { + text: string } function estimateTextWidthPx(text: string, fontSize: number): number { - // Empirical estimate for SF Pro bold numeric glyphs in tray-sized icons. - return Math.ceil(text.length * fontSize * 0.62 + fontSize * 0.2) + return Math.ceil(text.length * fontSize * 0.60 + fontSize * 0.2) } function getSvgLayout(args: { sizePx: number - percentText?: string + gridCells: TrayGridCell[] + hideIcon?: boolean }): { width: number height: number pad: number barsX: number - textX: number - textY: number - fontSize: number + iconSize: number + texts: { x: number; y: number; text: string; fontSize: number }[] } { - const { sizePx, percentText } = args - const hasPercentText = typeof percentText === "string" && percentText.length > 0 - const verticalNudgePx = 1 + const { sizePx, gridCells, hideIcon = false } = args const pad = Math.max(1, Math.round(sizePx * 0.08)) // ~2px at 24–36px const height = sizePx const barsX = pad - const fontSize = Math.max(9, Math.round(sizePx * 0.72)) - const textWidth = hasPercentText ? estimateTextWidthPx(percentText, fontSize) : 0 - // Optical correction + global nudge down to align with the tray slot center. - const textY = Math.round(sizePx / 2) + 1 + verticalNudgePx + const iconSize = Math.max(6, Math.round(sizePx - 2 * pad * 0.5)) - if (!hasPercentText) { + if (gridCells.length === 0) { return { + // Keep a non-zero canvas to avoid invalid raster/image dimensions. width: sizePx, height, pad, barsX, - textX: 0, - textY, - fontSize, + iconSize, + texts: [], } } + const visibleCells = gridCells.slice(0, 4) + + // Define layout configuration + // 1 item -> 1 col, 1 row (center) + // 2 items -> 1 col, 2 rows (stack) + // 3 items -> 2 cols, (col 1: 2 rows, col 2: top row) + // 4 items -> 2 cols, 2 rows per col + const numItems = visibleCells.length + const useTwoCols = numItems > 2 + const numRows = numItems > 1 ? 2 : 1 + + // Compute base fonts based on row count + const fontSize = numRows === 1 ? Math.max(9, Math.round(sizePx * 0.68)) : Math.max(8, Math.round(sizePx * 0.55)) const textGap = Math.max(2, Math.round(sizePx * 0.08)) - const textAreaWidth = Math.max(20, Math.round(sizePx * 1.5), textWidth + pad) - const rightPad = pad + const startX = hideIcon ? pad : sizePx + textGap + + // Measure columns and place texts + const texts: { x: number; y: number; text: string; fontSize: number }[] = [] + + let col1Width = 0 + let col2Width = 0 + + // Pass 1: measure max widths + for (let i = 0; i < visibleCells.length; i++) { + const w = estimateTextWidthPx(visibleCells[i].text, fontSize) + if (useTwoCols && i >= 2) { + col2Width = Math.max(col2Width, w) + } else { + col1Width = Math.max(col1Width, w) + } + } + + // Pass 2: Layout + // Column Gap, visual separator | + const colGapPx = useTwoCols ? Math.max(6, Math.round(sizePx * 0.3)) : 0 + + for (let i = 0; i < visibleCells.length; i++) { + const isCol2 = useTwoCols && i >= 2 + const isRow2 = i % 2 === 1 + + // X position + let textX = startX + if (isCol2) { + textX = startX + col1Width + colGapPx + } + + // Y position + let textY = Math.round(sizePx / 2) + 1 + if (numRows === 2) { + if (!isRow2) { + textY = Math.round(sizePx * 0.26) + 1 + } else { + textY = Math.round(sizePx * 0.78) + 1 + } + } + + texts.push({ + x: textX, + y: textY, + text: visibleCells[i].text, + fontSize + }) + } + + // Include separator line text logic if using two cols + if (useTwoCols) { + texts.push({ + x: startX + col1Width + Math.floor(colGapPx / 2), + y: Math.round(sizePx / 2) + 1, + text: "|", + fontSize: Math.max(10, Math.round(sizePx * 0.7)) + }) + } + + const totalTextWidth = col1Width + (useTwoCols ? colGapPx + col2Width : 0) return { - width: sizePx + textGap + textAreaWidth + rightPad, + width: Math.round(startX + totalTextWidth + pad), height, pad, barsX, - textX: sizePx + textGap, - textY, - fontSize, + iconSize, + texts, } } export function makeTrayBarsSvg(args: { sizePx: number - percentText?: string + gridCells?: TrayGridCell[] providerIconUrl?: string + hideIcon?: boolean }): string { - const { sizePx, percentText, providerIconUrl } = args - const text = normalizePercentText(percentText) + const { sizePx, providerIconUrl, gridCells = [], hideIcon = false } = args + const layout = getSvgLayout({ sizePx, - percentText: text, + gridCells, + hideIcon, }) const width = layout.width @@ -96,28 +161,32 @@ export function makeTrayBarsSvg(args: { `` ) - const iconSize = Math.max(6, Math.round(sizePx - 2 * layout.pad * 0.5)) const x = layout.barsX - const y = Math.round((height - iconSize) / 2) + 1 + const y = Math.round((height - layout.iconSize) / 2) + 1 const href = typeof providerIconUrl === "string" ? providerIconUrl.trim() : "" - if (href.length > 0) { - parts.push( - `` - ) - } else { - const cx = x + iconSize / 2 - const cy = y + iconSize / 2 - const radius = Math.max(2, iconSize / 2 - 1.5) - const strokeW = Math.max(1.5, Math.round(iconSize * 0.14)) - parts.push( - `` - ) + if (!hideIcon) { + if (href.length > 0) { + parts.push( + `` + ) + } else { + const cx = x + layout.iconSize / 2 + const cy = y + layout.iconSize / 2 + const radius = Math.max(2, layout.iconSize / 2 - 1.5) + const strokeW = Math.max(1.5, Math.round(layout.iconSize * 0.14)) + parts.push( + `` + ) + } } - if (text) { + // Align horizontal centers, baseline middle + for (const { x: tX, y: tY, text, fontSize } of layout.texts) { + const anchor = text === "|" ? "middle" : "start" + const opacity = text === "|" ? "0.3" : "1" parts.push( - `${escapeXmlText(text)}` + `${escapeXmlText(text)}` ) } @@ -160,19 +229,21 @@ async function rasterizeSvgToRgba(svg: string, widthPx: number, heightPx: number export async function renderTrayBarsIcon(args: { sizePx: number - percentText?: string + gridCells?: TrayGridCell[] providerIconUrl?: string + hideIcon?: boolean }): Promise { - const { sizePx, percentText, providerIconUrl } = args - const text = normalizePercentText(percentText) + const { sizePx, gridCells, providerIconUrl, hideIcon = false } = args const svg = makeTrayBarsSvg({ sizePx, - percentText: text, + gridCells, providerIconUrl, + hideIcon, }) const layout = getSvgLayout({ sizePx, - percentText: text, + gridCells: gridCells || [], + hideIcon, }) const rgba = await rasterizeSvgToRgba(svg, layout.width, layout.height) return await Image.new(rgba, layout.width, layout.height) diff --git a/src/lib/tray-primary-progress.test.ts b/src/lib/tray-primary-progress.test.ts index f2989a5b..10a12bdf 100644 --- a/src/lib/tray-primary-progress.test.ts +++ b/src/lib/tray-primary-progress.test.ts @@ -72,10 +72,10 @@ describe("getTrayPrimaryBars", () => { pluginId: "b", }) - expect(bars).toEqual([{ id: "b", fraction: 0.75 }]) + expect(bars).toEqual([{ id: "b", items: [{ label: "Session", fraction: 0.75 }] }]) }) - it("includes plugins with primary candidates even when no data (fraction undefined)", () => { + it("includes plugins with primary candidates even when no data (items empty)", () => { const bars = getTrayPrimaryBars({ pluginsMeta: [ { @@ -89,7 +89,7 @@ describe("getTrayPrimaryBars", () => { pluginSettings: { order: ["a"], disabled: [] }, pluginStates: { a: { data: null, loading: false, error: null } }, }) - expect(bars).toEqual([{ id: "a", fraction: undefined }]) + expect(bars).toEqual([{ id: "a", items: [] }]) }) it("computes fraction from matching progress label and clamps 0..1", () => { @@ -127,7 +127,7 @@ describe("getTrayPrimaryBars", () => { }, }) - expect(bars).toEqual([{ id: "a", fraction: 1 }]) + expect(bars).toEqual([{ id: "a", items: [{ label: "Plan usage", fraction: 1 }] }]) }) it("does not compute fraction when limit is 0", () => { @@ -163,7 +163,7 @@ describe("getTrayPrimaryBars", () => { }, }, }) - expect(bars).toEqual([{ id: "a", fraction: undefined }]) + expect(bars).toEqual([{ id: "a", items: [{ label: "Plan usage", fraction: undefined }] }]) }) it("respects displayMode=left", () => { @@ -200,7 +200,7 @@ describe("getTrayPrimaryBars", () => { }, }, }) - expect(bars).toEqual([{ id: "a", fraction: 0.75 }]) + expect(bars).toEqual([{ id: "a", items: [{ label: "Session", fraction: 0.75 }] }]) }) it("picks first available candidate from primaryCandidates", () => { @@ -238,7 +238,7 @@ describe("getTrayPrimaryBars", () => { }, }, }) - expect(bars).toEqual([{ id: "a", fraction: 0.5 }]) + expect(bars).toEqual([{ id: "a", items: [{ label: "Plan usage", fraction: 0.5 }] }]) }) it("uses first candidate when both are available", () => { @@ -283,7 +283,7 @@ describe("getTrayPrimaryBars", () => { }, }) // Should use Credits (20/100 = 0.2), not Plan usage (80/100 = 0.8) - expect(bars).toEqual([{ id: "a", fraction: 0.2 }]) + expect(bars).toEqual([{ id: "a", items: [{ label: "Credits", fraction: 0.2 }] }]) }) it("skips plugins with empty primaryCandidates", () => { diff --git a/src/lib/tray-primary-progress.ts b/src/lib/tray-primary-progress.ts index a139e29d..1c98a928 100644 --- a/src/lib/tray-primary-progress.ts +++ b/src/lib/tray-primary-progress.ts @@ -11,7 +11,7 @@ type PluginState = { export type TrayPrimaryBar = { id: string - fraction?: number + items: { label: string; fraction?: number }[] } type ProgressLine = Extract< @@ -52,16 +52,40 @@ export function getTrayPrimaryBars(args: { if (disabled.has(id)) continue const meta = metaById.get(id) if (!meta) continue - + // Skip if no primary candidates defined if (!meta.primaryCandidates || meta.primaryCandidates.length === 0) continue const state = pluginStates[id] const data = state?.data ?? null - let fraction: number | undefined + let items: { label: string; fraction?: number }[] = [] if (data) { - // Find first candidate that exists in runtime data + const configuredLabels = pluginSettings.trayLines?.[id] || [] + const targetLabels = configuredLabels.length > 0 + ? configuredLabels + : (meta.primaryCandidates.length > 0 ? [meta.primaryCandidates[0]] : []) + + for (const targetLabel of targetLabels) { + const line = data.lines.find( + (l): l is ProgressLine => isProgressLine(l) && l.label === targetLabel + ) + if (line) { + let fraction: number | undefined + if (line.limit > 0) { + const shownAmount = + displayMode === "used" + ? line.used + : line.limit - line.used + fraction = clamp01(shownAmount / line.limit) + } + items.push({ label: targetLabel, fraction }) + } + } + } + + // fallback to old logic if no matching lines found but we expected some + if (items.length === 0 && data) { const primaryLabel = meta.primaryCandidates.find((label) => data.lines.some((line) => isProgressLine(line) && line.label === label) ) @@ -75,12 +99,13 @@ export function getTrayPrimaryBars(args: { displayMode === "used" ? primaryLine.used : primaryLine.limit - primaryLine.used - fraction = clamp01(shownAmount / primaryLine.limit) + const fraction = clamp01(shownAmount / primaryLine.limit) + items.push({ label: primaryLabel, fraction }) } } } - out.push({ id, fraction }) + out.push({ id, items }) if (out.length >= maxBars) break } diff --git a/src/pages/settings.test.tsx b/src/pages/settings.test.tsx index 06e4a1b9..0686c27f 100644 --- a/src/pages/settings.test.tsx +++ b/src/pages/settings.test.tsx @@ -11,8 +11,8 @@ vi.mock("@dnd-kit/core", () => ({ return
{children}
}, closestCenter: vi.fn(), - PointerSensor: class {}, - KeyboardSensor: class {}, + PointerSensor: class { }, + KeyboardSensor: class { }, useSensor: vi.fn((_sensor: any, options?: any) => ({ sensor: _sensor, options })), useSensors: vi.fn((...sensors: any[]) => sensors), })) @@ -44,9 +44,10 @@ vi.mock("@dnd-kit/utilities", () => ({ import { SettingsPage } from "@/pages/settings" const defaultProps = { - plugins: [{ id: "a", name: "Alpha", enabled: true }], + plugins: [{ id: "a", name: "Alpha", enabled: true, primaryCandidates: [], trayLines: [] }], onReorder: vi.fn(), onToggle: vi.fn(), + onTrayLineToggle: vi.fn(), autoUpdateInterval: 15 as const, onAutoUpdateIntervalChange: vi.fn(), themeMode: "system" as const, @@ -72,7 +73,7 @@ describe("SettingsPage", () => { @@ -88,8 +89,8 @@ describe("SettingsPage", () => { diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index e42e39da..544414c6 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -36,6 +36,8 @@ interface PluginConfig { id: string; name: string; enabled: boolean; + primaryCandidates: string[]; + trayLines: string[]; } function SortablePluginItem({ @@ -100,6 +102,7 @@ interface SettingsPageProps { plugins: PluginConfig[]; onReorder: (orderedIds: string[]) => void; onToggle: (id: string) => void; + onTrayLineToggle: (id: string, lineLabel: string, checked: boolean, fallback?: string) => void; autoUpdateInterval: AutoUpdateIntervalMinutes; onAutoUpdateIntervalChange: (value: AutoUpdateIntervalMinutes) => void; themeMode: ThemeMode; @@ -112,12 +115,15 @@ interface SettingsPageProps { onGlobalShortcutChange: (value: GlobalShortcut) => void; startOnLogin: boolean; onStartOnLoginChange: (value: boolean) => void; + showTrayIcon: boolean; + onShowTrayIconChange: (value: boolean) => void; } export function SettingsPage({ plugins, onReorder, onToggle, + onTrayLineToggle, autoUpdateInterval, onAutoUpdateIntervalChange, themeMode, @@ -130,6 +136,8 @@ export function SettingsPage({ onGlobalShortcutChange, startOnLogin, onStartOnLoginChange, + showTrayIcon, + onShowTrayIconChange, }: SettingsPageProps) { const sensors = useSensors( useSensor(PointerSensor), @@ -277,6 +285,20 @@ export function SettingsPage({ globalShortcut={globalShortcut} onGlobalShortcutChange={onGlobalShortcutChange} /> +
+

System Tray

+

+ Visibility of the menu bar provider icon +

+ +

Start on Login

@@ -307,11 +329,31 @@ export function SettingsPage({ strategy={verticalListSortingStrategy} > {plugins.map((plugin) => ( - +

+ + {plugin.enabled && plugin.primaryCandidates.length > 0 && ( +
+ {plugin.primaryCandidates.map((candidateLabel, i) => { + const isSelected = plugin.trayLines.length === 0 + ? i === 0 + : plugin.trayLines.includes(candidateLabel); + return ( + + ) + })} +
+ )} +
))} diff --git a/src/stores/app-preferences-store.ts b/src/stores/app-preferences-store.ts index b8c22382..9df7f955 100644 --- a/src/stores/app-preferences-store.ts +++ b/src/stores/app-preferences-store.ts @@ -4,6 +4,7 @@ import { DEFAULT_DISPLAY_MODE, DEFAULT_GLOBAL_SHORTCUT, DEFAULT_RESET_TIMER_DISPLAY_MODE, + DEFAULT_SHOW_TRAY_ICON, DEFAULT_START_ON_LOGIN, DEFAULT_THEME_MODE, type AutoUpdateIntervalMinutes, @@ -20,12 +21,14 @@ type AppPreferencesStore = { resetTimerDisplayMode: ResetTimerDisplayMode globalShortcut: GlobalShortcut startOnLogin: boolean + showTrayIcon: boolean setAutoUpdateInterval: (value: AutoUpdateIntervalMinutes) => void setThemeMode: (value: ThemeMode) => void setDisplayMode: (value: DisplayMode) => void setResetTimerDisplayMode: (value: ResetTimerDisplayMode) => void setGlobalShortcut: (value: GlobalShortcut) => void setStartOnLogin: (value: boolean) => void + setShowTrayIcon: (value: boolean) => void resetState: () => void } @@ -36,6 +39,7 @@ const initialState = { resetTimerDisplayMode: DEFAULT_RESET_TIMER_DISPLAY_MODE, globalShortcut: DEFAULT_GLOBAL_SHORTCUT, startOnLogin: DEFAULT_START_ON_LOGIN, + showTrayIcon: DEFAULT_SHOW_TRAY_ICON, } export const useAppPreferencesStore = create((set) => ({ @@ -46,5 +50,6 @@ export const useAppPreferencesStore = create((set) => ({ setResetTimerDisplayMode: (value) => set({ resetTimerDisplayMode: value }), setGlobalShortcut: (value) => set({ globalShortcut: value }), setStartOnLogin: (value) => set({ startOnLogin: value }), + setShowTrayIcon: (value) => set({ showTrayIcon: value }), resetState: () => set(initialState), })) From 77a3b05189040a66e00b17aa4a90d42443d3de96 Mon Sep 17 00:00:00 2001 From: zia Date: Wed, 25 Feb 2026 01:17:18 +0800 Subject: [PATCH 2/2] fix(settings): address trayLines review issues --- src/lib/settings.test.ts | 3 ++ src/lib/settings.ts | 3 +- src/pages/settings.tsx | 104 +++++++++++++++++++++------------------ 3 files changed, 60 insertions(+), 50 deletions(-) diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 313c0cad..99e02042 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -102,9 +102,12 @@ describe("settings", () => { const b = { order: ["a"], disabled: [], trayLines: {} } const c = { order: ["b"], disabled: [], trayLines: {} } const d = { order: ["a"], disabled: [], trayLines: { "a": ["x"] } } + const e = { order: ["a"], disabled: [], trayLines: { "a": [] } } + const f = { order: ["a"], disabled: [], trayLines: { "b": [] } } expect(arePluginSettingsEqual(a, b)).toBe(true) expect(arePluginSettingsEqual(a, c)).toBe(false) expect(arePluginSettingsEqual(a, d)).toBe(false) + expect(arePluginSettingsEqual(e, f)).toBe(false) }) it("returns enabled plugin ids", () => { diff --git a/src/lib/settings.ts b/src/lib/settings.ts index a8fb1c05..1cd431c8 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -142,7 +142,7 @@ export function normalizePluginSettings( disabled.push(id); } } - const trayLines = { ...settings.trayLines }; + const trayLines = { ...(settings.trayLines ?? {}) }; for (const key in trayLines) { if (!knownSet.has(key)) { delete trayLines[key]; @@ -172,6 +172,7 @@ export function arePluginSettingsEqual( if (aKeys.length !== bKeys.length) return false; for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(bLines, key)) return false; const aArr = aLines[key] || []; const bArr = bLines[key] || []; if (aArr.length !== bArr.length) return false; diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 544414c6..38a99003 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -16,6 +16,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { GripVertical } from "lucide-react"; +import type { ReactNode } from "react"; import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@/components/ui/button"; import { GlobalShortcutSection } from "@/components/global-shortcut-section"; @@ -43,9 +44,11 @@ interface PluginConfig { function SortablePluginItem({ plugin, onToggle, + trayLinesContent, }: { plugin: PluginConfig; onToggle: (id: string) => void; + trayLinesContent?: ReactNode; }) { const { attributes, @@ -66,34 +69,36 @@ function SortablePluginItem({ ref={setNodeRef} style={style} className={cn( - "flex items-center gap-3 px-3 py-2 rounded-md bg-card", - "border border-transparent", - isDragging && "opacity-50 border-border" + "flex flex-col gap-1", + isDragging && "opacity-50" )} > - +
+ - - {plugin.name} - + + {plugin.name} + - onToggle(plugin.id)} - /> + onToggle(plugin.id)} + /> +
+ {trayLinesContent} ); } @@ -329,31 +334,32 @@ export function SettingsPage({ strategy={verticalListSortingStrategy} > {plugins.map((plugin) => ( -
- - {plugin.enabled && plugin.primaryCandidates.length > 0 && ( -
- {plugin.primaryCandidates.map((candidateLabel, i) => { - const isSelected = plugin.trayLines.length === 0 - ? i === 0 - : plugin.trayLines.includes(candidateLabel); - return ( - - ) - })} -
- )} -
+ 0 ? ( +
+ {plugin.primaryCandidates.map((candidateLabel, i) => { + const isSelected = plugin.trayLines.length === 0 + ? i === 0 + : plugin.trayLines.includes(candidateLabel); + return ( + + ); + })} +
+ ) : undefined + } + /> ))}