From 5591393a04c142612d43d1f20f061ee9966f2838 Mon Sep 17 00:00:00 2001 From: Praise Adesokan Date: Sat, 4 Apr 2026 20:18:21 +0100 Subject: [PATCH 1/8] Stabilize tray panel height so provider changes stop shifting the UI --- src/App.test.tsx | 24 ++++++++++++++++++++++++ src/components/app/app-shell.tsx | 5 +++-- src/hooks/app/use-panel.ts | 20 +++++++++++--------- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index 9c556942..358e4ee1 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1168,6 +1168,30 @@ describe("App", () => { await waitFor(() => expect(state.setSizeMock).toHaveBeenCalled()) }) + it("keeps a stable preferred panel height when content is shorter", async () => { + state.isTauriMock.mockReturnValue(true) + state.currentMonitorMock.mockResolvedValueOnce({ size: { height: 1000 } }) + render() + + await waitFor(() => + expect(state.setSizeMock).toHaveBeenCalledWith( + expect.objectContaining({ width: 400, height: 560 }) + ) + ) + }) + + it("clamps the stable panel height to small monitors", async () => { + state.isTauriMock.mockReturnValue(true) + state.currentMonitorMock.mockResolvedValueOnce({ size: { height: 500 } }) + render() + + await waitFor(() => + expect(state.setSizeMock).toHaveBeenCalledWith( + expect.objectContaining({ width: 400, height: 400 }) + ) + ) + }) + it("resizes again via ResizeObserver callback", async () => { state.isTauriMock.mockReturnValue(true) const OriginalResizeObserver = globalThis.ResizeObserver diff --git a/src/components/app/app-shell.tsx b/src/components/app/app-shell.tsx index a0d5406b..ed800681 100644 --- a/src/components/app/app-shell.tsx +++ b/src/components/app/app-shell.tsx @@ -54,7 +54,7 @@ export function AppShell({ containerRef, scrollRef, canScrollDown, - maxPanelHeightPx, + panelHeightPx, } = usePanel({ activeView, setActiveView, @@ -65,13 +65,14 @@ export function AppShell({ const appVersion = useAppVersion() const { updateStatus, triggerInstall, checkForUpdates } = useAppUpdate() + const cardHeightPx = panelHeightPx ? Math.max(1, panelHeightPx - ARROW_OVERHEAD_PX) : null return (
(null) const scrollRef = useRef(null) const [canScrollDown, setCanScrollDown] = useState(false) - const [maxPanelHeightPx, setMaxPanelHeightPx] = useState(null) - const maxPanelHeightPxRef = useRef(null) + const [panelHeightPx, setPanelHeightPx] = useState(null) + const panelHeightPxRef = useRef(null) useEffect(() => { if (!isTauri()) return @@ -89,7 +90,6 @@ export function usePanel({ const resizeWindow = async () => { const factor = window.devicePixelRatio const width = Math.ceil(PANEL_WIDTH * factor) - const desiredHeightLogical = Math.max(1, container.scrollHeight) let maxHeightPhysical: number | null = null let maxHeightLogical: number | null = null @@ -110,13 +110,15 @@ export function usePanel({ maxHeightPhysical = Math.floor(maxHeightLogical * factor) } - if (maxPanelHeightPxRef.current !== maxHeightLogical) { - maxPanelHeightPxRef.current = maxHeightLogical - setMaxPanelHeightPx(maxHeightLogical) + // Keep the tray panel visually stable; scrolling should happen inside the shell. + const nextPanelHeightLogical = Math.max(1, Math.min(PANEL_PREFERRED_HEIGHT, maxHeightLogical)) + + if (panelHeightPxRef.current !== nextPanelHeightLogical) { + panelHeightPxRef.current = nextPanelHeightLogical + setPanelHeightPx(nextPanelHeightLogical) } - const desiredHeightPhysical = Math.ceil(desiredHeightLogical * factor) - const height = Math.ceil(Math.min(desiredHeightPhysical, maxHeightPhysical!)) + const height = Math.ceil(Math.min(nextPanelHeightLogical * factor, maxHeightPhysical!)) try { const currentWindow = getCurrentWindow() @@ -164,6 +166,6 @@ export function usePanel({ containerRef, scrollRef, canScrollDown, - maxPanelHeightPx, + panelHeightPx, } } From c484adaf2568eaf167bbd39a8dd50306eb1aa1e7 Mon Sep 17 00:00:00 2001 From: Praise Adesokan Date: Sat, 4 Apr 2026 21:28:16 +0100 Subject: [PATCH 2/8] Document stable tray height choice to make the panel sizing fix clearer --- src/hooks/app/use-panel.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/app/use-panel.ts b/src/hooks/app/use-panel.ts index 915ccb13..078ab5ca 100644 --- a/src/hooks/app/use-panel.ts +++ b/src/hooks/app/use-panel.ts @@ -5,6 +5,7 @@ import { getCurrentWindow, PhysicalSize, currentMonitor } from "@tauri-apps/api/ import type { ActiveView } from "@/components/side-nav" const PANEL_WIDTH = 400 +// Keep the tray shell stable across short and long provider states; overflow should scroll inside the panel. const PANEL_PREFERRED_HEIGHT = 560 const MAX_HEIGHT_FALLBACK_PX = 600 const MAX_HEIGHT_FRACTION_OF_MONITOR = 0.8 From a2ebde888d536b7400326ed87e9e550e257d525f Mon Sep 17 00:00:00 2001 From: Praise Adesokan Date: Sat, 4 Apr 2026 23:43:50 +0100 Subject: [PATCH 3/8] Refine stable panel sizing for review feedback and monitor changes --- src/App.test.tsx | 18 +++++++-- src/components/app/app-shell.tsx | 5 +-- src/hooks/app/use-panel.test.ts | 62 +++++++++++++++++++++++++--- src/hooks/app/use-panel.ts | 69 +++++++++++++++++++++++++++----- 4 files changed, 132 insertions(+), 22 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index 358e4ee1..fc5445db 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -8,6 +8,8 @@ const state = vi.hoisted(() => ({ isTauriMock: vi.fn(() => false), trackMock: vi.fn(), setSizeMock: vi.fn(), + windowOnMovedMock: vi.fn(), + windowOnScaleChangedMock: vi.fn(), currentMonitorMock: vi.fn(), startBatchMock: vi.fn(), savePluginSettingsMock: vi.fn(), @@ -172,7 +174,11 @@ vi.mock("@tauri-apps/api/path", () => ({ })) vi.mock("@tauri-apps/api/window", () => ({ - getCurrentWindow: () => ({ setSize: state.setSizeMock }), + getCurrentWindow: () => ({ + setSize: state.setSizeMock, + onMoved: state.windowOnMovedMock, + onScaleChanged: state.windowOnScaleChangedMock, + }), PhysicalSize: class { width: number height: number @@ -259,6 +265,8 @@ describe("App", () => { state.isTauriMock.mockReturnValue(false) state.trackMock.mockReset() state.setSizeMock.mockReset() + state.windowOnMovedMock.mockReset() + state.windowOnScaleChangedMock.mockReset() state.currentMonitorMock.mockReset() state.startBatchMock.mockReset() state.savePluginSettingsMock.mockReset() @@ -320,6 +328,8 @@ describe("App", () => { state.autostartDisableMock.mockResolvedValue(undefined) state.autostartIsEnabledMock.mockResolvedValue(false) state.renderTrayBarsIconMock.mockResolvedValue({}) + state.windowOnMovedMock.mockResolvedValue(() => {}) + state.windowOnScaleChangedMock.mockResolvedValue(() => {}) Object.defineProperty(HTMLElement.prototype, "scrollHeight", { configurable: true, get() { @@ -1168,9 +1178,9 @@ describe("App", () => { await waitFor(() => expect(state.setSizeMock).toHaveBeenCalled()) }) - it("keeps a stable preferred panel height when content is shorter", async () => { + it("uses the preferred stable panel height on standard monitors", async () => { state.isTauriMock.mockReturnValue(true) - state.currentMonitorMock.mockResolvedValueOnce({ size: { height: 1000 } }) + state.currentMonitorMock.mockResolvedValue({ size: { height: 1000 } }) render() await waitFor(() => @@ -1182,7 +1192,7 @@ describe("App", () => { it("clamps the stable panel height to small monitors", async () => { state.isTauriMock.mockReturnValue(true) - state.currentMonitorMock.mockResolvedValueOnce({ size: { height: 500 } }) + state.currentMonitorMock.mockResolvedValue({ size: { height: 500 } }) render() await waitFor(() => diff --git a/src/components/app/app-shell.tsx b/src/components/app/app-shell.tsx index ed800681..be52aeb5 100644 --- a/src/components/app/app-shell.tsx +++ b/src/components/app/app-shell.tsx @@ -60,12 +60,11 @@ export function AppShell({ setActiveView, showAbout, setShowAbout, - displayPlugins, }) const appVersion = useAppVersion() const { updateStatus, triggerInstall, checkForUpdates } = useAppUpdate() - const cardHeightPx = panelHeightPx ? Math.max(1, panelHeightPx - ARROW_OVERHEAD_PX) : null + const cardHeightPx = panelHeightPx !== null ? Math.max(0, panelHeightPx - ARROW_OVERHEAD_PX) : null return (
@@ -85,7 +84,7 @@ export function AppShell({ />
-
+
({ invokeMock: vi.fn(), isTauriMock: vi.fn(), listenMock: vi.fn(), getCurrentWindowMock: vi.fn(), currentMonitorMock: vi.fn(), + onMovedMock: vi.fn(), + onScaleChangedMock: vi.fn(), })) vi.mock("@tauri-apps/api/core", () => ({ @@ -47,11 +52,20 @@ describe("usePanel", () => { listenMock.mockReset() getCurrentWindowMock.mockReset() currentMonitorMock.mockReset() + onMovedMock.mockReset() + onScaleChangedMock.mockReset() isTauriMock.mockReturnValue(true) invokeMock.mockResolvedValue(undefined) + listenMock.mockResolvedValue(vi.fn()) currentMonitorMock.mockResolvedValue(null) - getCurrentWindowMock.mockReturnValue({ setSize: vi.fn().mockResolvedValue(undefined) }) + onMovedMock.mockResolvedValue(vi.fn()) + onScaleChangedMock.mockResolvedValue(vi.fn()) + getCurrentWindowMock.mockReturnValue({ + setSize: vi.fn().mockResolvedValue(undefined), + onMoved: onMovedMock, + onScaleChanged: onScaleChangedMock, + }) }) it("handles tray show-about event", async () => { @@ -69,7 +83,6 @@ describe("usePanel", () => { setActiveView: vi.fn(), showAbout: false, setShowAbout, - displayPlugins: [], }) ) @@ -103,7 +116,6 @@ describe("usePanel", () => { setActiveView: vi.fn(), showAbout: false, setShowAbout: vi.fn(), - displayPlugins: [], }) ) @@ -135,7 +147,6 @@ describe("usePanel", () => { setActiveView: vi.fn(), showAbout: false, setShowAbout: vi.fn(), - displayPlugins: [], }) ) @@ -150,4 +161,45 @@ describe("usePanel", () => { expect(unlistenShowAbout).toHaveBeenCalledTimes(1) }) }) + + it("recalculates panel sizing when the window scale changes", async () => { + const setSize = vi.fn().mockResolvedValue(undefined) + let scaleChangedHandler: (() => void) | null = null + + currentMonitorMock.mockResolvedValue({ size: { height: 1000 } }) + onScaleChangedMock.mockImplementation(async (handler: () => void) => { + scaleChangedHandler = handler + return vi.fn() + }) + getCurrentWindowMock.mockReturnValue({ + setSize, + onMoved: onMovedMock, + onScaleChanged: onScaleChangedMock, + }) + + function Harness() { + const { containerRef, scrollRef } = usePanel({ + activeView: "home", + setActiveView: vi.fn(), + showAbout: false, + setShowAbout: vi.fn(), + }) + + return createElement("div", { ref: containerRef }, createElement("div", { ref: scrollRef })) + } + + render(createElement(Harness)) + + await waitFor(() => { + expect(setSize).toHaveBeenCalledTimes(1) + }) + + act(() => { + scaleChangedHandler?.() + }) + + await waitFor(() => { + expect(currentMonitorMock).toHaveBeenCalledTimes(2) + }) + }) }) diff --git a/src/hooks/app/use-panel.ts b/src/hooks/app/use-panel.ts index 078ab5ca..293a240a 100644 --- a/src/hooks/app/use-panel.ts +++ b/src/hooks/app/use-panel.ts @@ -5,6 +5,8 @@ import { getCurrentWindow, PhysicalSize, currentMonitor } from "@tauri-apps/api/ import type { ActiveView } from "@/components/side-nav" const PANEL_WIDTH = 400 +const PANEL_WINDOW_OVERHEAD_PX = 37 +const PANEL_MIN_CONTENT_HEIGHT_PX = 120 // Keep the tray shell stable across short and long provider states; overflow should scroll inside the panel. const PANEL_PREFERRED_HEIGHT = 560 const MAX_HEIGHT_FALLBACK_PX = 600 @@ -15,7 +17,6 @@ type UsePanelArgs = { setActiveView: (view: ActiveView) => void showAbout: boolean setShowAbout: (value: boolean) => void - displayPlugins: unknown[] } export function usePanel({ @@ -23,13 +24,13 @@ export function usePanel({ setActiveView, showAbout, setShowAbout, - displayPlugins, }: UsePanelArgs) { const containerRef = useRef(null) const scrollRef = useRef(null) const [canScrollDown, setCanScrollDown] = useState(false) const [panelHeightPx, setPanelHeightPx] = useState(null) const panelHeightPxRef = useRef(null) + const lastWindowSizeRef = useRef<{ width: number; height: number } | null>(null) useEffect(() => { if (!isTauri()) return @@ -87,6 +88,9 @@ export function usePanel({ if (!isTauri()) return const container = containerRef.current if (!container) return + const currentWindow = getCurrentWindow() + let cancelled = false + const unlisteners: (() => void)[] = [] const resizeWindow = async () => { const factor = window.devicePixelRatio @@ -111,33 +115,78 @@ export function usePanel({ maxHeightPhysical = Math.floor(maxHeightLogical * factor) } + const minPanelHeightLogical = Math.min( + maxHeightLogical, + PANEL_WINDOW_OVERHEAD_PX + PANEL_MIN_CONTENT_HEIGHT_PX + ) + // Keep the tray panel visually stable; scrolling should happen inside the shell. - const nextPanelHeightLogical = Math.max(1, Math.min(PANEL_PREFERRED_HEIGHT, maxHeightLogical)) + const nextPanelHeightLogical = Math.max( + minPanelHeightLogical, + Math.min(PANEL_PREFERRED_HEIGHT, maxHeightLogical) + ) if (panelHeightPxRef.current !== nextPanelHeightLogical) { panelHeightPxRef.current = nextPanelHeightLogical setPanelHeightPx(nextPanelHeightLogical) } - const height = Math.ceil(Math.min(nextPanelHeightLogical * factor, maxHeightPhysical!)) + const nextWindowSize = { + width, + height: Math.ceil(Math.min(nextPanelHeightLogical * factor, maxHeightPhysical!)), + } + + if ( + lastWindowSizeRef.current?.width === nextWindowSize.width && + lastWindowSizeRef.current?.height === nextWindowSize.height + ) { + return + } + + lastWindowSizeRef.current = nextWindowSize try { - const currentWindow = getCurrentWindow() - await currentWindow.setSize(new PhysicalSize(width, height)) + await currentWindow.setSize(new PhysicalSize(nextWindowSize.width, nextWindowSize.height)) } catch (e) { console.error("Failed to resize window:", e) } } - resizeWindow() + void resizeWindow() const observer = new ResizeObserver(() => { - resizeWindow() + void resizeWindow() }) observer.observe(container) - return () => observer.disconnect() - }, [activeView, displayPlugins]) + async function setupWindowListeners() { + const unlistenMoved = await currentWindow.onMoved(() => { + void resizeWindow() + }) + if (cancelled) { + unlistenMoved() + return + } + unlisteners.push(unlistenMoved) + + const unlistenScaleChanged = await currentWindow.onScaleChanged(() => { + void resizeWindow() + }) + if (cancelled) { + unlistenScaleChanged() + return + } + unlisteners.push(unlistenScaleChanged) + } + + void setupWindowListeners() + + return () => { + cancelled = true + observer.disconnect() + for (const fn of unlisteners) fn() + } + }, []) useEffect(() => { const el = scrollRef.current From 2af6d0c286c18ad95707ba0aa26c8f9fe3fc689f Mon Sep 17 00:00:00 2001 From: Praise Adesokan Date: Sat, 4 Apr 2026 23:56:10 +0100 Subject: [PATCH 4/8] Polish panel scrollbar styling and spacing for the new scroll layout --- src/components/app/app-shell.test.tsx | 100 ++++++++++++++++++++++++++ src/components/app/app-shell.tsx | 2 +- src/index.css | 24 +++++++ 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 src/components/app/app-shell.test.tsx diff --git a/src/components/app/app-shell.test.tsx b/src/components/app/app-shell.test.tsx new file mode 100644 index 00000000..592ab06c --- /dev/null +++ b/src/components/app/app-shell.test.tsx @@ -0,0 +1,100 @@ +import { render, screen } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const state = vi.hoisted(() => ({ + usePanelMock: vi.fn(), + useAppVersionMock: vi.fn(), + useAppUpdateMock: vi.fn(), +})) + +vi.mock("@/components/app/app-content", () => ({ + AppContent: () =>
, +})) + +vi.mock("@/components/panel-footer", () => ({ + PanelFooter: () =>
, +})) + +vi.mock("@/components/side-nav", () => ({ + SideNav: () =>
, +})) + +vi.mock("@/hooks/app/use-panel", () => ({ + usePanel: state.usePanelMock, +})) + +vi.mock("@/hooks/app/use-app-version", () => ({ + useAppVersion: state.useAppVersionMock, +})) + +vi.mock("@/hooks/use-app-update", () => ({ + useAppUpdate: state.useAppUpdateMock, +})) + +vi.mock("@/stores/app-ui-store", () => ({ + useAppUiStore: () => ({ + activeView: "home", + setActiveView: vi.fn(), + showAbout: false, + setShowAbout: vi.fn(), + }), +})) + +import { AppShell } from "@/components/app/app-shell" + +function createProps() { + return { + onRefreshAll: vi.fn(), + navPlugins: [], + displayPlugins: [], + settingsPlugins: [], + autoUpdateNextAt: null, + selectedPlugin: null, + onPluginContextAction: vi.fn(), + isPluginRefreshAvailable: vi.fn(), + onNavReorder: vi.fn(), + appContentProps: { + onRetryPlugin: vi.fn(), + onReorder: vi.fn(), + onToggle: vi.fn(), + onAutoUpdateIntervalChange: vi.fn(), + onThemeModeChange: vi.fn(), + onDisplayModeChange: vi.fn(), + onResetTimerDisplayModeChange: vi.fn(), + onResetTimerDisplayModeToggle: vi.fn(), + onMenubarIconStyleChange: vi.fn(), + traySettingsPreview: { + active: false, + bars: [], + iconUrl: null, + primaryLabel: null, + }, + onGlobalShortcutChange: vi.fn(), + onStartOnLoginChange: vi.fn(), + }, + } +} + +describe("AppShell", () => { + beforeEach(() => { + state.usePanelMock.mockReturnValue({ + containerRef: { current: null }, + scrollRef: { current: null }, + canScrollDown: false, + panelHeightPx: 560, + }) + state.useAppVersionMock.mockReturnValue("0.0.0-test") + state.useAppUpdateMock.mockReturnValue({ + updateStatus: { status: "idle" }, + triggerInstall: vi.fn(), + checkForUpdates: vi.fn(), + }) + }) + + it("uses the styled panel scrollbar class on the scroll region", () => { + const { container } = render() + + expect(screen.getByTestId("app-content")).toBeInTheDocument() + expect(container.querySelector(".panel-scroll")).toBeTruthy() + }) +}) diff --git a/src/components/app/app-shell.tsx b/src/components/app/app-shell.tsx index be52aeb5..f9b318e2 100644 --- a/src/components/app/app-shell.tsx +++ b/src/components/app/app-shell.tsx @@ -84,7 +84,7 @@ export function AppShell({ />
-
+
Date: Sun, 5 Apr 2026 00:37:34 +0100 Subject: [PATCH 5/8] Move the panel scrollbar toward the outer edge without crowding content --- src/components/app/app-shell.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/app/app-shell.tsx b/src/components/app/app-shell.tsx index f9b318e2..0a50f8df 100644 --- a/src/components/app/app-shell.tsx +++ b/src/components/app/app-shell.tsx @@ -82,15 +82,17 @@ export function AppShell({ isPluginRefreshAvailable={isPluginRefreshAvailable} onReorder={onNavReorder} /> -
+
-
- +
+
+ +
Date: Sun, 5 Apr 2026 00:41:02 +0100 Subject: [PATCH 6/8] Align the footer spacing with the updated panel content gutter --- src/components/panel-footer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/panel-footer.tsx b/src/components/panel-footer.tsx index 03f77180..0517a5c1 100644 --- a/src/components/panel-footer.tsx +++ b/src/components/panel-footer.tsx @@ -113,7 +113,7 @@ export function PanelFooter({ return ( <> -
+
Date: Sun, 5 Apr 2026 00:48:55 +0100 Subject: [PATCH 7/8] Add more space between the scrollbar and the panel content --- src/components/app/app-shell.tsx | 2 +- src/components/panel-footer.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/app/app-shell.tsx b/src/components/app/app-shell.tsx index 0a50f8df..e62cdb43 100644 --- a/src/components/app/app-shell.tsx +++ b/src/components/app/app-shell.tsx @@ -85,7 +85,7 @@ export function AppShell({
-
+
-
+
Date: Sun, 5 Apr 2026 14:37:06 +0300 Subject: [PATCH 8/8] refactor: change panel layout and scrollbar styling --- src/components/app/app-shell.test.tsx | 3 +++ src/components/app/app-shell.tsx | 4 ++-- src/components/panel-footer.tsx | 2 +- src/index.css | 18 ++++-------------- 4 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/components/app/app-shell.test.tsx b/src/components/app/app-shell.test.tsx index 592ab06c..92d55d94 100644 --- a/src/components/app/app-shell.test.tsx +++ b/src/components/app/app-shell.test.tsx @@ -96,5 +96,8 @@ describe("AppShell", () => { expect(screen.getByTestId("app-content")).toBeInTheDocument() expect(container.querySelector(".panel-scroll")).toBeTruthy() + const contentWrapper = screen.getByTestId("panel-content-wrapper") + expect(contentWrapper).toBeTruthy() + expect(contentWrapper.querySelector("[data-testid='app-content']")).toBeTruthy() }) }) diff --git a/src/components/app/app-shell.tsx b/src/components/app/app-shell.tsx index e62cdb43..90f46825 100644 --- a/src/components/app/app-shell.tsx +++ b/src/components/app/app-shell.tsx @@ -82,10 +82,10 @@ export function AppShell({ isPluginRefreshAvailable={isPluginRefreshAvailable} onReorder={onNavReorder} /> -
+
-
+
-
+