mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(memory): keep Trees toolbar stable when View Vault panel opens (#4342)
This commit is contained in:
@@ -19,7 +19,7 @@
|
||||
* (Flatpak/Snap/portable). "Open anyway" and the config-dir override are the
|
||||
* escape hatches for that case; a false "not registered" never blocks the user.
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { ToastNotification } from '../../types/intelligence';
|
||||
@@ -58,6 +58,35 @@ export function ObsidianVaultSection({ contentRootAbs, onToast }: ObsidianVaultS
|
||||
const [configFound, setConfigFound] = useState<boolean | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [configDir, setConfigDir] = useState<string>(readConfigDirOverride);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const closePanel = useCallback(() => setExpanded(false), []);
|
||||
|
||||
// The guidance panel is a floating popover, so it needs explicit dismissal:
|
||||
// click outside the section or press Escape to close it. (Clicking the View
|
||||
// Vault button itself stays inside `containerRef`, so it re-runs the check
|
||||
// rather than dismissing — matching the panel's "click View Vault again" copy.)
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
console.debug('[ui-flow][obsidian-vault] dismiss: outside click');
|
||||
setExpanded(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
console.debug('[ui-flow][obsidian-vault] dismiss: escape');
|
||||
setExpanded(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [expanded]);
|
||||
|
||||
/**
|
||||
* Build + fire the `obsidian://` deep link.
|
||||
@@ -192,8 +221,14 @@ export function ObsidianVaultSection({ contentRootAbs, onToast }: ObsidianVaultS
|
||||
? t('workspace.obsidianNotFoundHelp')
|
||||
: t('workspace.vaultNotRegisteredHelp');
|
||||
|
||||
// The guidance panel is rendered as an absolutely-positioned popover anchored
|
||||
// to the button (out of normal flow) rather than as an inline sibling. This
|
||||
// component lives inside the horizontal MemoryControls toolbar; an in-flow
|
||||
// `w-full`/wide panel would grow this flex item and force the whole toolbar to
|
||||
// wrap/misalign (issue #4266). Taking the panel out of flow keeps the toolbar
|
||||
// row stable regardless of the panel's visibility.
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-2" data-testid="obsidian-vault-section">
|
||||
<div className="relative inline-flex" data-testid="obsidian-vault-section" ref={containerRef}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -208,8 +243,19 @@ export function ObsidianVaultSection({ contentRootAbs, onToast }: ObsidianVaultS
|
||||
{expanded && (
|
||||
<div
|
||||
data-testid="obsidian-vault-guidance"
|
||||
className="w-full max-w-xl rounded-lg border border-violet-200 bg-violet-50 p-4
|
||||
text-sm dark:border-violet-500/30 dark:bg-violet-500/10">
|
||||
className="absolute right-0 top-full z-20 mt-2 w-[36rem] max-w-[calc(100vw-2rem)]
|
||||
rounded-lg border border-violet-200 bg-violet-50 p-4 pr-10 text-sm shadow-xl
|
||||
dark:border-violet-500/30 dark:bg-violet-950">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closePanel}
|
||||
data-testid="obsidian-vault-close"
|
||||
aria-label={t('common.close')}
|
||||
className="absolute right-2 top-2 rounded-md p-1 text-content-muted
|
||||
hover:bg-violet-100 hover:text-content-secondary
|
||||
dark:hover:bg-violet-500/20 dark:hover:text-content">
|
||||
<CloseIcon />
|
||||
</button>
|
||||
<p className="text-content-secondary">{helpText}</p>
|
||||
|
||||
<code
|
||||
@@ -288,6 +334,24 @@ export function ObsidianVaultSection({ contentRootAbs, onToast }: ObsidianVaultS
|
||||
);
|
||||
}
|
||||
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<path d="M18 6L6 18" />
|
||||
<path d="M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalLinkIcon() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -66,6 +66,71 @@ describe('ObsidianVaultSection', () => {
|
||||
expect(screen.getByTestId('obsidian-vault-path')).toHaveTextContent(ROOT);
|
||||
});
|
||||
|
||||
// #4266: the section lives inside the horizontal MemoryControls toolbar, so
|
||||
// the guidance panel must render out of normal flow (absolute popover) — an
|
||||
// in-flow/`w-full` panel grows the flex item and reflows the whole toolbar.
|
||||
it('guidance panel renders out of flow so the toolbar never reflows', async () => {
|
||||
memoryTreeObsidianVaultStatus.mockResolvedValue(status());
|
||||
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
|
||||
|
||||
const panel = await screen.findByTestId('obsidian-vault-guidance');
|
||||
expect(panel).toHaveClass('absolute');
|
||||
expect(panel).not.toHaveClass('w-full');
|
||||
// The section itself stays inline (sized to the button), not a full-width column.
|
||||
expect(screen.getByTestId('obsidian-vault-section')).toHaveClass('inline-flex');
|
||||
});
|
||||
|
||||
// #4266: as a floating popover the panel overlays the graph, so its background
|
||||
// must be opaque — the old translucent `dark:bg-violet-500/10` let content
|
||||
// bleed through and made the text unreadable.
|
||||
it('guidance panel has an opaque background (does not bleed through)', async () => {
|
||||
memoryTreeObsidianVaultStatus.mockResolvedValue(status());
|
||||
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
|
||||
|
||||
const panel = await screen.findByTestId('obsidian-vault-guidance');
|
||||
expect(panel).toHaveClass('bg-violet-50');
|
||||
expect(panel).toHaveClass('dark:bg-violet-950');
|
||||
expect(panel).not.toHaveClass('dark:bg-violet-500/10');
|
||||
});
|
||||
|
||||
// #4266: the floating panel needs explicit dismissal — close button, Escape,
|
||||
// and click-outside all collapse it.
|
||||
it('close button dismisses the guidance panel', async () => {
|
||||
memoryTreeObsidianVaultStatus.mockResolvedValue(status());
|
||||
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
|
||||
fireEvent.click(await screen.findByTestId('obsidian-vault-close'));
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('obsidian-vault-guidance')).toBeNull());
|
||||
});
|
||||
|
||||
it('Escape key dismisses the guidance panel', async () => {
|
||||
memoryTreeObsidianVaultStatus.mockResolvedValue(status());
|
||||
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
|
||||
await screen.findByTestId('obsidian-vault-guidance');
|
||||
fireEvent.keyDown(document.body, { key: 'Escape' });
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('obsidian-vault-guidance')).toBeNull());
|
||||
});
|
||||
|
||||
it('clicking outside the section dismisses the guidance panel', async () => {
|
||||
memoryTreeObsidianVaultStatus.mockResolvedValue(status());
|
||||
renderWithProviders(<ObsidianVaultSection contentRootAbs={ROOT} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
|
||||
await screen.findByTestId('obsidian-vault-guidance');
|
||||
fireEvent.mouseDown(document.body);
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('obsidian-vault-guidance')).toBeNull());
|
||||
});
|
||||
|
||||
it('"Open anyway" fires the deep link even when unregistered', async () => {
|
||||
memoryTreeObsidianVaultStatus.mockResolvedValue(status());
|
||||
const onToast = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user