mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
fix: pet hover card gets cut off at screen edges (#2860)
* fix: pet hover card gets cut off at screen edges * fix: address review feedback - viewport clamping + unadopted pet wake - Add window.innerHeight check to prevent bottom-edge clipping - Increase menuH estimate for safer positioning - Open pet settings instead of no-op Wake for unadopted pets * fix: address review feedback on pet menu positioning and wake action - Add viewport height check (viewH) to prevent bottom-edge clipping - Increase menuH estimate for safer positioning - Open pet settings instead of no-op Wake for unadopted pets
This commit is contained in:
parent
cdf34897ba
commit
8ec162bb26
1 changed files with 114 additions and 0 deletions
|
|
@ -283,6 +283,9 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
const toolsMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const toolsTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const petEnabled = Boolean(onAdoptPet && onTogglePet);
|
||||
const [petMenuOpen, setPetMenuOpen] = useState(false);
|
||||
const petWrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const [petMenuStyle, setPetMenuStyle] = useState<React.CSSProperties>({});
|
||||
const linkedDirs = projectMetadata?.linkedDirs ?? [];
|
||||
// initialDraft is only honored on the first non-empty value the parent
|
||||
// hands us. After we seed once, the composer is fully under user control
|
||||
|
|
@ -326,6 +329,53 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
};
|
||||
}, [toolsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!petMenuOpen) return;
|
||||
function onPointer(e: MouseEvent) {
|
||||
const target = e.target as Node;
|
||||
if (petWrapRef.current?.contains(target)) return;
|
||||
setPetMenuOpen(false);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setPetMenuOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', onPointer);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [petMenuOpen]);
|
||||
|
||||
// Viewport-aware pet menu positioning — flips the popover to stay
|
||||
// within screen bounds instead of clipping at the edge.
|
||||
useEffect(() => {
|
||||
if (!petMenuOpen) return;
|
||||
const wrap = petWrapRef.current;
|
||||
if (!wrap) return;
|
||||
const rect = wrap.getBoundingClientRect();
|
||||
const menuW = 260;
|
||||
const menuH = 200;
|
||||
const gap = 6;
|
||||
const viewW = window.innerWidth;
|
||||
const viewH = window.innerHeight;
|
||||
// Prefer opening upward (bottom of menu above the button).
|
||||
// Flip downward when there isn't enough room above.
|
||||
// When neither direction fits, clamp to viewport bounds.
|
||||
let top: number;
|
||||
if (rect.top >= menuH + gap) {
|
||||
top = rect.top - menuH - gap;
|
||||
} else if (rect.bottom + menuH + gap <= viewH) {
|
||||
top = rect.bottom + gap;
|
||||
} else {
|
||||
top = Math.max(gap, viewH - menuH - gap);
|
||||
}
|
||||
// Right-align by default (menu right edge ≈ button right edge).
|
||||
// Shift left when the menu would spill past the viewport left edge.
|
||||
const left = Math.max(8, Math.min(viewW - menuW - 8, rect.right - menuW));
|
||||
setPetMenuStyle({ position: 'fixed', top, left });
|
||||
}, [petMenuOpen]);
|
||||
|
||||
// Lazy-fetch the user's external MCP servers list once on mount so the
|
||||
// `/mcp …` slash palette and the composer's MCP button popover have
|
||||
// something to render. We deliberately do not reactively re-fetch when
|
||||
|
|
@ -1706,6 +1756,70 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{petEnabled ? (
|
||||
<div className="composer-pet-wrap" ref={petWrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`composer-pet${petConfig?.adopted ? ' adopted' : ''}`}
|
||||
onClick={() => {
|
||||
if (petConfig?.adopted) {
|
||||
if (!petConfig.enabled) setPetMenuOpen(true);
|
||||
else setPetMenuOpen((v) => !v);
|
||||
} else {
|
||||
setPetMenuOpen((v) => !v);
|
||||
}
|
||||
}}
|
||||
title={t('pet.composerTitle')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={petMenuOpen}
|
||||
aria-label={t('pet.composerTitle')}
|
||||
>
|
||||
<span className="composer-pet-glyph">
|
||||
{petConfig?.adopted ? (petConfig?.custom?.glyph || '🐾') : '🐾'}
|
||||
</span>
|
||||
<span className="composer-pet-label">
|
||||
{petConfig?.adopted ? (petConfig?.custom?.name || 'Buddy') : t('pet.composerMenuTitle')}
|
||||
</span>
|
||||
</button>
|
||||
{petMenuOpen ? (
|
||||
<div
|
||||
className="composer-pet-menu"
|
||||
style={petMenuStyle}
|
||||
>
|
||||
<div className="composer-pet-menu-head">
|
||||
<strong>{t('pet.composerMenuTitle')}</strong>
|
||||
<span>{t('pet.composerMenuHint')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="composer-pet-menu-row toggle"
|
||||
onClick={() => {
|
||||
if (petConfig?.adopted) {
|
||||
onTogglePet?.();
|
||||
} else {
|
||||
onOpenPetSettings?.();
|
||||
}
|
||||
setPetMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Icon name={petConfig?.enabled ? 'eye-off' : 'eye'} size={12} />
|
||||
<span>{petConfig?.enabled ? t('pet.tuck') : t('pet.wake')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="composer-pet-menu-row settings"
|
||||
onClick={() => {
|
||||
onOpenPetSettings?.();
|
||||
setPetMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Icon name="settings" size={12} />
|
||||
<span>{t('pet.composerOpenSettings')}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
className="icon-btn"
|
||||
data-testid="chat-attach"
|
||||
|
|
|
|||
Loading…
Reference in a new issue