fix(memory): keep Trees toolbar stable when View Vault panel opens (#4342)

This commit is contained in:
Cyrus Gray
2026-06-30 18:57:24 +05:30
committed by GitHub
parent 7a524720c7
commit cdb5711f44
2 changed files with 133 additions and 4 deletions
@@ -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();