Skip to content
24 changes: 24 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App />)

await waitFor(() =>
expect(state.setSizeMock).toHaveBeenCalledWith(
expect.objectContaining({ width: 400, height: 560 })
)
)
})
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test name/intent mismatch: "keeps a stable preferred panel height when content is shorter" doesn't currently set up a short-content scenario or verify stability across a provider/view change; it only asserts the initial resize target. Either adjust the name to match what’s asserted or extend the test to simulate a content/view change and confirm the height remains unchanged.

Copilot uses AI. Check for mistakes.

it("clamps the stable panel height to small monitors", async () => {
state.isTauriMock.mockReturnValue(true)
state.currentMonitorMock.mockResolvedValueOnce({ size: { height: 500 } })
render(<App />)

await waitFor(() =>
expect(state.setSizeMock).toHaveBeenCalledWith(
expect.objectContaining({ width: 400, height: 400 })
)
)
})
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests use currentMonitorMock.mockResolvedValueOnce(...), but usePanel can call currentMonitor() multiple times (initial call + subsequent ResizeObserver callbacks). Using mockResolvedValueOnce makes the tests fragile and can mask regressions in later resize calls. Prefer mockResolvedValue(...) (or assert on the last setSize call / no subsequent call exceeding the clamp).

Copilot uses AI. Check for mistakes.

it("resizes again via ResizeObserver callback", async () => {
state.isTauriMock.mockReturnValue(true)
const OriginalResizeObserver = globalThis.ResizeObserver
Expand Down
5 changes: 3 additions & 2 deletions src/components/app/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function AppShell({
containerRef,
scrollRef,
canScrollDown,
maxPanelHeightPx,
panelHeightPx,
} = usePanel({
activeView,
setActiveView,
Expand All @@ -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 (
<div ref={containerRef} className="flex flex-col items-center p-6 pt-1.5 bg-transparent">
<div className="tray-arrow" />
<div
className="relative bg-card rounded-xl overflow-hidden select-none w-full border shadow-lg flex flex-col"
style={maxPanelHeightPx ? { maxHeight: `${maxPanelHeightPx - ARROW_OVERHEAD_PX}px` } : undefined}
style={cardHeightPx ? { height: `${cardHeightPx}px`, maxHeight: `${cardHeightPx}px` } : undefined}
>
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cardHeightPx is derived from panelHeightPx using a fixed ARROW_OVERHEAD_PX, but cardHeightPx is clamped to at least 1px. If panelHeightPx is ever < ARROW_OVERHEAD_PX (possible with small monitors/high DPI), the card + overhead will no longer fit in the window and the top/bottom chrome may be clipped. Consider clamping panelHeightPx to at least ARROW_OVERHEAD_PX + 1 (or computing these values in one place) to keep the layout internally consistent.

Copilot uses AI. Check for mistakes.
<div className="flex flex-1 min-h-0 flex-row">
<SideNav
Expand Down
21 changes: 12 additions & 9 deletions src/hooks/app/use-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ 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

Expand All @@ -26,8 +28,8 @@ export function usePanel({
const containerRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const [canScrollDown, setCanScrollDown] = useState(false)
const [maxPanelHeightPx, setMaxPanelHeightPx] = useState<number | null>(null)
const maxPanelHeightPxRef = useRef<number | null>(null)
const [panelHeightPx, setPanelHeightPx] = useState<number | null>(null)
const panelHeightPxRef = useRef<number | null>(null)

useEffect(() => {
if (!isTauri()) return
Expand Down Expand Up @@ -89,7 +91,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
Expand All @@ -110,13 +111,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))

Comment on lines +123 to +128
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nextPanelHeightLogical can be clamped below the shell's non-content overhead (padding + arrow), especially on very small monitors or high devicePixelRatio. In that case the UI will be clipped because AppShell enforces a minimum card height of 1px. Consider enforcing a minimum panel height that accounts for the shell overhead (or moving the overhead constant into the sizing logic) so the total window height can always fit the chrome.

Copilot uses AI. Check for mistakes.
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!))
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resizeWindow now sets a fixed preferred height, but the effect still reruns on activeView/displayPlugins changes. Since displayPlugins updates frequently, this can cause repeated currentMonitor() calls and setSize() calls even when the target size hasn't changed. Consider removing displayPlugins from the dependency list (or running this effect only on mount) and/or caching the last {width,height} in a ref to skip no-op setSize calls.

Copilot uses AI. Check for mistakes.

try {
const currentWindow = getCurrentWindow()
Expand Down Expand Up @@ -164,6 +167,6 @@ export function usePanel({
containerRef,
scrollRef,
canScrollDown,
maxPanelHeightPx,
panelHeightPx,
}
}
Loading