+
({
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 f0838738..293a240a 100644
--- a/src/hooks/app/use-panel.ts
+++ b/src/hooks/app/use-panel.ts
@@ -5,6 +5,10 @@ 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
const MAX_HEIGHT_FRACTION_OF_MONITOR = 0.8
@@ -13,7 +17,6 @@ type UsePanelArgs = {
setActiveView: (view: ActiveView) => void
showAbout: boolean
setShowAbout: (value: boolean) => void
- displayPlugins: unknown[]
}
export function usePanel({
@@ -21,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 [maxPanelHeightPx, setMaxPanelHeightPx] = useState(null)
- const maxPanelHeightPxRef = useRef(null)
+ const [panelHeightPx, setPanelHeightPx] = useState(null)
+ const panelHeightPxRef = useRef(null)
+ const lastWindowSizeRef = useRef<{ width: number; height: number } | null>(null)
useEffect(() => {
if (!isTauri()) return
@@ -85,11 +88,13 @@ 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
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,31 +115,78 @@ export function usePanel({
maxHeightPhysical = Math.floor(maxHeightLogical * factor)
}
- if (maxPanelHeightPxRef.current !== maxHeightLogical) {
- maxPanelHeightPxRef.current = maxHeightLogical
- setMaxPanelHeightPx(maxHeightLogical)
+ 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(
+ minPanelHeightLogical,
+ 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 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
@@ -164,6 +216,6 @@ export function usePanel({
containerRef,
scrollRef,
canScrollDown,
- maxPanelHeightPx,
+ panelHeightPx,
}
}
diff --git a/src/index.css b/src/index.css
index ec8200cc..1abefa73 100644
--- a/src/index.css
+++ b/src/index.css
@@ -158,6 +158,20 @@ body,
display: none; /* Chrome / Safari / WebKit */
}
+/* In-panel scrollbar: thin, no reserved idle gutter. */
+.panel-scroll {
+ --scrollbar-thumb: color-mix(in oklch, var(--muted-foreground) 72%, transparent);
+ scrollbar-width: thin;
+ scrollbar-color: var(--scrollbar-thumb) transparent;
+}
+.panel-scroll::-webkit-scrollbar-track {
+ background: transparent;
+}
+.panel-scroll::-webkit-scrollbar-thumb {
+ background: var(--scrollbar-thumb);
+ border-radius: 999px;
+}
+
/* Arrow pointing up toward the tray icon */
.tray-arrow {
width: 0;