Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion e2e/chat-popup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ test("chat popup connects, streams a reply, and supports panel docking", async (
await expect(page.getByTestId("desktop-root")).toBeVisible();

await page.getByRole("button", { name: "Chat" }).click();
await expect(page.getByText("Hello from the fake gateway")).toBeVisible();
await expect(page.getByTestId("chat-popup")).toBeVisible();

const chatInput = page.locator("textarea").last();
await chatInput.fill("What changed?");
Expand Down Expand Up @@ -201,6 +201,70 @@ test("chat popup lets you switch to Local AI when it is configured", async ({ pa
await expect(page.getByText(/Switched chat to Gemma 4 Local/)).toBeVisible();
});

test("chat popup provider dropdown stays visible at viewport edges", async ({ page }) => {
await page.setViewportSize({ width: 640, height: 260 });
await installFakeGatewaySocket(page);

await installClawboxMocks(page, {
initialSetup: {
setup_complete: true,
wifi_configured: true,
update_completed: true,
password_configured: true,
ai_model_configured: true,
local_ai_configured: true,
local_ai_provider: "llamacpp",
local_ai_model: "llamacpp/gemma4-e2b-it-q4_0",
telegram_configured: true,
},
preferences: {
ui_mascot_hidden: 1,
},
});

await page.goto("/");
await expect(page.getByTestId("desktop-root")).toBeVisible();

await page.getByRole("button", { name: "Chat" }).click();
await expect(page.getByText("Hello from the fake gateway")).toBeVisible();

await page.getByTestId("chat-popup").evaluate((el) => {
Object.assign(el.style, {
left: "128px",
top: "180px",
right: "auto",
bottom: "auto",
width: "416px",
height: "220px",
});
});

const providerTrigger = page.getByRole("button", { name: "Chat provider" });
await providerTrigger.click();

const listbox = page.getByRole("listbox", { name: "Chat provider" });
await expect(listbox).toBeVisible();
await expect(providerTrigger).toHaveAttribute("aria-controls", await listbox.getAttribute("id") ?? "");
await page.waitForTimeout(150);

const bounds = await listbox.evaluate((el) => {
const rect = el.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
};
});

expect(bounds.left).toBeGreaterThanOrEqual(8);
expect(bounds.top).toBeGreaterThanOrEqual(8);
expect(bounds.right).toBeLessThanOrEqual(bounds.viewportWidth - 8);
expect(bounds.bottom).toBeLessThanOrEqual(bounds.viewportHeight - 8);
});

test("chat popup opens Local AI settings when local AI is not configured", async ({ page }) => {
await installFakeGatewaySocket(page);

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "clawbox-setup",
"version": "3.1.8",
"version": "3.1.9",
"private": true,
"description": "ClawBox setup wizard and dashboard",
"scripts": {
Expand Down
77 changes: 73 additions & 4 deletions src/components/HeaderDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
'use client'

import type { PointerEvent as ReactPointerEvent } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'

export interface HeaderDropdownOption {
id: string
Expand Down Expand Up @@ -52,12 +53,68 @@ export function HeaderDropdown({
onPointerDown,
}: HeaderDropdownProps) {
const [open, setOpen] = useState(false)
// Viewport-space (position: fixed) coordinates for the open popover.
// The popover is portaled to <body> so it can't be clipped by the chat
// window's `overflow: hidden` — see the flip/shift logic below.
const [coords, setCoords] = useState<{ left: number; top: number; maxHeight: number } | null>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const activeOption = options.find(o => o.id === value)
const listboxId = useId()

const close = useCallback(() => setOpen(false), [])

// Position the open popover in viewport coordinates, flipping above the
// trigger when there isn't room below and shifting horizontally so it
// never spills past a viewport edge. Recomputes on scroll/resize so it
// stays glued to the trigger while the chat window moves.
useLayoutEffect(() => {
if (!open) return
const compute = () => {
const t = triggerRef.current?.getBoundingClientRect()
if (!t) return
const margin = 8
const gap = 6
const maxDesired = 320
const vw = window.innerWidth
const vh = window.innerHeight

// Horizontal: align the popover's left edge to the trigger, but pull
// it back inside if `popoverWidth` would overrun the right edge.
let left = t.left
if (left + popoverWidth > vw - margin) {
left = t.right - popoverWidth
}
left = Math.max(margin, Math.min(left, vw - popoverWidth - margin))

// Vertical: prefer opening below; flip above when there's more room
// there. Cap `maxHeight` to the available space so the list scrolls
// internally instead of being clipped.
const spaceBelow = vh - t.bottom - gap - margin
const spaceAbove = t.top - gap - margin
let top: number
let maxHeight: number
const minPreferredHeight = 160
if (spaceBelow >= minPreferredHeight || spaceBelow >= spaceAbove) {
top = t.bottom + gap
maxHeight = Math.min(maxDesired, spaceBelow)
} else {
maxHeight = Math.min(maxDesired, spaceAbove)
top = Math.max(margin, t.top - gap - maxHeight)
}

setCoords({ left, top, maxHeight: Math.max(maxHeight, 0) })
}
compute()
window.addEventListener('resize', compute)
// Capture-phase so scrolling any ancestor (e.g. the chat body) repositions.
window.addEventListener('scroll', compute, true)
return () => {
window.removeEventListener('resize', compute)
window.removeEventListener('scroll', compute, true)
}
}, [open, popoverWidth])
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Close on outside click / Esc.
useEffect(() => {
if (!open) return
Expand Down Expand Up @@ -95,6 +152,7 @@ export function HeaderDropdown({
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? listboxId : undefined}
disabled={disabled}
onClick={() => !disabled && setOpen(o => !o)}
className="header-dropdown-trigger"
Expand All @@ -111,13 +169,23 @@ export function HeaderDropdown({
expand_more
</span>
</button>
{open && (
{open && coords && createPortal(
<div
ref={popoverRef}
id={listboxId}
role="listbox"
aria-label={ariaLabel}
className="header-dropdown-popover"
style={{ width: popoverWidth }}
onPointerDown={onPointerDown}
style={{
position: 'fixed',
left: coords.left,
top: coords.top,
width: popoverWidth,
maxHeight: coords.maxHeight,
// Above the chat popup (zIndex 10010) so it is never clipped.
zIndex: 10050,
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
{options.map(option => {
const isActive = option.id === value
Expand Down Expand Up @@ -148,7 +216,8 @@ export function HeaderDropdown({
</button>
)
})}
</div>
</div>,
document.body,
)}
</div>
)
Expand Down
Loading