diff --git a/src/web-ui/AGENTS.md b/src/web-ui/AGENTS.md index 19e61f7c1d..5127a7addf 100644 --- a/src/web-ui/AGENTS.md +++ b/src/web-ui/AGENTS.md @@ -289,6 +289,15 @@ establish visual or remote transport behavior): pnpm --dir src/web-ui run test:run src/shared/ui/OverlayStack.test.tsx src/shared/ui/OverlayOwnership.contract.test.ts src/shared/ui/Dialog.test.tsx src/shared/ui/MenuPopover.test.tsx src/infrastructure/appearance/runtime/AppearanceOverlayHost.test.ts src/shared/notification-system/components/NotificationContainer.test.tsx src/shared/context-menu-system/components/ui/ContextMenu.test.tsx ``` +For global-search source diagnostics, retry, portal ownership and layered Escape dismissal: + +```bash +pnpm --dir src/web-ui run test:run src/app/global-search src/shared/ui/OverlayStack.test.tsx src/shared/ui/Dialog.test.tsx +``` + +These DOM fixtures include simulated Peer surface switches and viewport geometry; +they do not establish native rendering or live remote behavior. + For the compact model picker, smart-mode items, session/default selection, and host capability negotiation: diff --git a/src/web-ui/src/app/global-search/GlobalSearchContent.test.tsx b/src/web-ui/src/app/global-search/GlobalSearchContent.test.tsx index bc42d5320a..5df255791c 100644 --- a/src/web-ui/src/app/global-search/GlobalSearchContent.test.tsx +++ b/src/web-ui/src/app/global-search/GlobalSearchContent.test.tsx @@ -3,7 +3,8 @@ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { activateSurface, resetDeviceSurfaceForTest } from '@/infrastructure/peer-device/deviceSurface'; -import { GlobalSearchContent } from './GlobalSearchRoot'; +import GlobalSearchRoot, { GlobalSearchContent } from './GlobalSearchRoot'; +import { useGlobalSearchStore } from './globalSearchStore'; import type { GlobalSearchSnapshot } from './types'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -43,17 +44,39 @@ const snapshot: GlobalSearchSnapshot = { ], }; +const diagnosticSnapshot: GlobalSearchSnapshot = { ...snapshot, diagnostics: [{ + providerId: 'files', code: 'workspace_unavailable', message: 'Connection timed out', + workspaceName: 'Remote project', workspacePath: '/srv/project', hostLabel: 'build-host', +}] }; + describe('GlobalSearchContent accessibility', () => { let container: HTMLDivElement; let root: Root; + const diagnosticPanel = () => document.querySelector('.global-search__diagnostics-panel'); + const diagnosticTrigger = () => document.querySelector('[data-testid$="-partial-status"] button')!; + const key = (target: Element, value: string, isComposing = false) => act(() => { + target.dispatchEvent(new KeyboardEvent('keydown', { key: value, bubbles: true, cancelable: true, isComposing })); + }); + const renderDiagnostics = async (modal = false) => { + fixtures.search.mockImplementationOnce((_providers, _request, _signal, onUpdate) => onUpdate(diagnosticSnapshot)); + if (modal) useGlobalSearchStore.getState().openSearch('needle'); + act(() => root.render(modal ? : )); + await act(async () => { await vi.runOnlyPendingTimersAsync(); }); + return diagnosticTrigger(); + }; beforeEach(() => { resetDeviceSurfaceForTest(); + useGlobalSearchStore.setState({ open: false, initialQuery: '' }); vi.useFakeTimers(); fixtures.activate.mockReset(); fixtures.search.mockReset(); fixtures.search.mockImplementation((_providers, _request, _signal, onUpdate) => onUpdate(snapshot)); HTMLElement.prototype.scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollTo = vi.fn(); vi.stubGlobal('CSS', { escape: (value: string) => value }); + const media = new EventTarget(); + Object.defineProperty(media, 'matches', { value: false }); + vi.stubGlobal('matchMedia', () => media); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -61,7 +84,10 @@ describe('GlobalSearchContent accessibility', () => { afterEach(() => { act(() => root.unmount()); container.remove(); + useGlobalSearchStore.setState({ open: false, initialQuery: '' }); + document.querySelectorAll('[data-openbitfun-overlay-host]').forEach(element => element.remove()); vi.useRealTimers(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it('retains named native actions and tracks keyboard selection in the controlled grid', async () => { @@ -83,25 +109,154 @@ describe('GlobalSearchContent accessibility', () => { expect(fixtures.activate).toHaveBeenCalledWith(snapshot.items[0].target, expect.anything()); }); it('shows source diagnostics and retries without clearing the query or healthy results', async () => { - const failure = { ...snapshot, diagnostics: [{ - providerId: 'files', code: 'workspace_unavailable', message: 'Connection timed out', - workspaceName: 'Remote project', workspacePath: '/srv/project', hostLabel: 'build-host', - }] }; - fixtures.search.mockImplementationOnce((_providers, _request, _signal, onUpdate) => onUpdate(failure)); + fixtures.search.mockImplementationOnce((_providers, _request, _signal, onUpdate) => onUpdate(diagnosticSnapshot)); fixtures.search.mockImplementationOnce((_providers, _request, _signal, onUpdate, retry) => onUpdate({ ...retry.previous, isSearching: true })); act(() => root.render()); await act(async () => { await vi.runOnlyPendingTimersAsync(); }); - const details = container.querySelector('details')!; - expect(details.textContent).toContain('Connection timed out'); - expect(details.textContent).toContain('/srv/project'); - expect(details.textContent).toContain('build-host'); - act(() => details.querySelector('button')!.click()); + act(() => diagnosticTrigger().click()); + const panel = diagnosticPanel()!; + expect(panel.textContent).toContain('Connection timed out'); + expect(panel.textContent).toContain('/srv/project'); + expect(panel.textContent).toContain('build-host'); + act(() => panel.querySelector('button')!.click()); await act(async () => { await vi.runOnlyPendingTimersAsync(); }); - expect(fixtures.search.mock.calls[1][4]).toEqual({ previous: failure, providerIds: ['files'] }); + expect(fixtures.search.mock.calls[1][4]).toEqual({ previous: diagnosticSnapshot, providerIds: ['files'] }); expect(container.querySelector('input')?.value).toBe('needle'); expect(container.textContent).toContain('Open terminal'); - expect(details.querySelector('button')?.disabled).toBe(true); + expect(panel.querySelector('button')?.disabled).toBe(true); + }); + + it('toggles a named diagnostics portal through its owning trigger', async () => { + const trigger = await renderDiagnostics(); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + expect(diagnosticPanel()).toBeNull(); + act(() => trigger.click()); + const panel = diagnosticPanel()!; + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + expect(trigger.getAttribute('aria-controls')).toBe(panel.id); + expect(panel.getAttribute('role')).toBe('dialog'); + expect(panel.getAttribute('aria-labelledby')).toBe(trigger.id); + expect(panel.closest('[data-openbitfun-overlay-layer]')).not.toBeNull(); + expect(container.contains(panel)).toBe(false); + expect(document.activeElement).toBe(panel); + act(() => { + trigger.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); + trigger.click(); + }); + expect(diagnosticPanel()).toBeNull(); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it.each(['panel', 'query'])('dismisses diagnostics before the real search Dialog with focus in the %s', async (focus) => { + const trigger = await renderDiagnostics(true); + act(() => trigger.click()); + const panel = diagnosticPanel()!; + const dialog = document.querySelector('[data-testid="global-search-dialog"]')!; + const target = focus === 'panel' ? panel : dialog.querySelector('input')!; + act(() => target.focus()); + expect(document.activeElement).toBe(target); + key(target, 'Escape', true); + expect(diagnosticPanel()).toBe(panel); + key(target, 'Escape'); + expect(diagnosticPanel()).toBeNull(); + expect(useGlobalSearchStore.getState().open).toBe(true); + expect(dialog.getAttribute('data-state')).toBe('open'); + expect(document.activeElement).toBe(trigger); + key(trigger, 'Escape'); + expect(useGlobalSearchStore.getState().open).toBe(false); + await act(async () => { await vi.runOnlyPendingTimersAsync(); }); + expect(document.querySelector('[data-testid="global-search-dialog"]')).toBeNull(); + }); + + it('keeps portalled retry controls inside the modal focus domain and preserves outside focus on dismissal', async () => { + const trigger = await renderDiagnostics(true); + act(() => trigger.click()); + const panel = diagnosticPanel()!; + const retry = panel.querySelector('button')!; + act(() => { + retry.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); + retry.focus(); + }); + expect(diagnosticPanel()).toBe(panel); + expect(document.activeElement).toBe(retry); + const input = document.querySelector('[data-testid="global-search-dialog"] input')!; + act(() => { + input.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); + input.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + input.focus(); + }); + expect(diagnosticPanel()).toBeNull(); + expect(useGlobalSearchStore.getState().open).toBe(true); + expect(document.activeElement).toBe(input); + }); + + it('dismisses only the top layer for each outside pointer gesture', async () => { + const trigger = await renderDiagnostics(true); + act(() => trigger.click()); + const backdrop = document.querySelector('[data-testid="global-search-dialog"]')!.parentElement!; + const clickBackdrop = () => { + act(() => backdrop.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))); + act(() => backdrop.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))); + }; + clickBackdrop(); + expect(diagnosticPanel()).toBeNull(); + expect(useGlobalSearchStore.getState().open).toBe(true); + clickBackdrop(); + expect(useGlobalSearchStore.getState().open).toBe(false); + }); + + it('dismisses diagnostics before leaving a group detail page or closing the search Dialog', async () => { + const grouped: GlobalSearchSnapshot = { ...diagnosticSnapshot, items: ['one', 'two'].map(id => ({ + id, providerId: 'workspaces', group: 'workspaces', title: id, score: 80, + target: { kind: 'workspace', workspaceId: id }, + })) }; + fixtures.search.mockImplementationOnce((_providers, _request, _signal, onUpdate) => onUpdate(grouped)); + useGlobalSearchStore.getState().openSearch(); + act(() => root.render()); + await act(async () => { await vi.runOnlyPendingTimersAsync(); }); + act(() => document.querySelector('[data-testid="global-search-group-drilldown-workspaces"]')!.click()); + await act(async () => { await vi.runOnlyPendingTimersAsync(); }); + const view = document.querySelector('.global-search')!; + expect(view.getAttribute('data-search-view')).toBe('workspaces'); + act(() => diagnosticTrigger().click()); + key(diagnosticPanel()!, 'Escape'); + expect(diagnosticPanel()).toBeNull(); + expect(view.getAttribute('data-search-view')).toBe('workspaces'); + expect(useGlobalSearchStore.getState().open).toBe(true); + key(diagnosticTrigger(), 'Escape'); + expect(view.getAttribute('data-search-view')).toBe('overview'); + expect(useGlobalSearchStore.getState().open).toBe(true); + key(diagnosticTrigger(), 'Escape'); + expect(useGlobalSearchStore.getState().open).toBe(false); + }); + + it('clamps diagnostics near viewport edges instead of clipping them to the search container', async () => { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + return this.classList.contains('global-search__diagnostics-panel') + ? new DOMRect(0, 0, 300, 200) + : new DOMRect(window.innerWidth - 100, window.innerHeight - 40, 80, 24); + }); + const trigger = await renderDiagnostics(true); + act(() => trigger.click()); + const panel = diagnosticPanel()!; + expect(Number.parseFloat(panel.style.left) + 300).toBeLessThanOrEqual(window.innerWidth - 8); + expect(Number.parseFloat(panel.style.top) + 200).toBeLessThan(window.innerHeight - 40); + expect(panel.style.visibility).toBe('visible'); + }); + + it('removes old-host diagnostics immediately when switching devices', async () => { + const trigger = await renderDiagnostics(true); + act(() => trigger.click()); + expect(diagnosticPanel()?.textContent).toContain('/srv/project'); + const [, , signal, publishOld] = fixtures.search.mock.calls[0]; + act(() => activateSurface('peer-b')); + expect(signal.aborted).toBe(true); + expect(diagnosticPanel()).toBeNull(); + expect(document.querySelector('[data-testid="global-search-partial-status"]')).toBeNull(); + act(() => publishOld(diagnosticSnapshot)); + expect(diagnosticPanel()).toBeNull(); + expect(document.body.textContent).not.toContain('/srv/project'); }); it('discards old results and ignores late completions after a device switch', async () => { diff --git a/src/web-ui/src/app/global-search/GlobalSearchRoot.scss b/src/web-ui/src/app/global-search/GlobalSearchRoot.scss index e448388e10..9cb1ee5a27 100644 --- a/src/web-ui/src/app/global-search/GlobalSearchRoot.scss +++ b/src/web-ui/src/app/global-search/GlobalSearchRoot.scss @@ -891,16 +891,77 @@ $chevron-box-slack: 5px; .global-search__diagnostics { min-width: 0; - flex: 1; - max-height: 200px; - overflow: auto; - summary { cursor: pointer; } + margin-right: auto; + color: var(--openbitfun-color-content-muted); +} + +.global-search__diagnostics-summary { + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-meta-font-size); + margin-left: -6px; + + &[aria-expanded='true'] { + background: var(--openbitfun-color-action-quiet-hover); + color: var(--openbitfun-color-content-primary); + } +} + +.global-search__diagnostics-icon { + flex: 0 0 auto; + color: var(--openbitfun-color-content-secondary); +} + +.global-search__diagnostics-panel { + position: fixed; + box-sizing: border-box; + width: max-content; + min-width: min(280px, calc(100vw - 16px)); + max-width: min(480px, calc(100vw - 16px)); + max-height: min(240px, calc(100vh - 16px)); + overflow-y: auto; + padding: 10px 14px; + background: var(--openbitfun-color-surface-raised); + border: 1px solid var(--openbitfun-color-border-subtle); + border-radius: var(--openbitfun-radius-md); + box-shadow: var(--openbitfun-shadow-overlay); + color: var(--openbitfun-color-content-primary); + font-family: var(--openbitfun-type-body-sm-font-family); + font-size: var(--openbitfun-type-meta-font-size); +} + +.global-search__diagnostic-target { + margin-block-end: 8px; + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-meta-font-size); } -.global-search__diagnostic-target { margin-block: 8px; } + .global-search__diagnostic { padding-block: 8px; border-top: 1px solid var(--openbitfun-color-border-subtle); overflow-wrap: anywhere; - p { margin-block: 4px 8px; } + + strong { + color: var(--openbitfun-color-content-primary); + font-weight: var(--openbitfun-type-label-selected-font-weight); + } + + p { + margin-block: 4px 8px; + color: var(--openbitfun-color-content-secondary); + line-height: var(--openbitfun-type-body-sm-line-height); + } +} + +.global-search__diagnostic-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.global-search__diagnostic-path { + color: var(--openbitfun-color-content-muted); + font-family: var(--openbitfun-type-code-sm-font-family); + font-size: var(--openbitfun-type-micro-font-size); + margin-block-start: 2px; } -.global-search__diagnostic-path { color: var(--openbitfun-color-content-muted); } diff --git a/src/web-ui/src/app/global-search/GlobalSearchRoot.tsx b/src/web-ui/src/app/global-search/GlobalSearchRoot.tsx index 34723d4db9..9ffb2fadc7 100644 --- a/src/web-ui/src/app/global-search/GlobalSearchRoot.tsx +++ b/src/web-ui/src/app/global-search/GlobalSearchRoot.tsx @@ -19,8 +19,9 @@ import { OverflowText, ScrollArea, Dialog, DialogBody, + Portal, } from '@openbitfun/ui'; -import { BarChart3, Blocks, CheckSquare2, FileText, Keyboard, MessageSquareText, MessagesSquare, Network, Users } from 'lucide-react'; +import { AlertCircle, BarChart3, Blocks, CheckSquare2, FileText, Keyboard, MessageSquareText, MessagesSquare, Network, Users } from 'lucide-react'; import { useShortcut } from '@/infrastructure/hooks/useShortcut'; import { useI18n } from '@/infrastructure/i18n'; import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; @@ -30,6 +31,7 @@ import { useMyAgentStore } from '@/app/scenes/my-agent/myAgentStore'; import { useNurseryStore } from '@/app/scenes/profile/nurseryStore'; import { notificationService } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; +import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { activateGlobalSearchTarget } from './globalSearchActivator'; import { runFederatedSearch } from './federatedSearchEngine'; import { globalSearchRegistry } from './globalSearchRegistry'; @@ -183,12 +185,30 @@ export const GlobalSearchContent: React.FC = ({ const retryRef = useRef<{ context: string; providerId: string; previous: GlobalSearchSnapshot } | null>(null); const [activeId, setActiveId] = useState(null); const [drilldownGroup, setDrilldownGroup] = useState(null); + const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); + const diagnosticsTriggerRef = useRef(null); + const diagnosticsPanelRef = useRef(null); const inputRef = useRef(null); const inputCompositionActiveRef = useRef(false); const listRef = useRef(null); const generatedId = useId().replace(/:/g, ''); const instanceId = `global-search-${generatedId || 'content'}`; const resultsId = `${instanceId}-results`; + const diagnosticsId = `${instanceId}-diagnostics`; + const diagnosticsTriggerId = `${diagnosticsId}-trigger`; + const diagnosticsVisible = active && diagnosticsOpen && snapshot.diagnostics.length > 0; + const diagnosticsLayout = useAnchoredPopoverPosition({ + open: diagnosticsVisible, + anchorRef: diagnosticsTriggerRef, + popoverRef: diagnosticsPanelRef, + preferredPlacement: 'top', + gap: 8, + layoutRevision: snapshot.diagnostics, + }); + const diagnosticsPositioned = diagnosticsLayout !== null; + useEffect(() => { + if (diagnosticsVisible && diagnosticsPositioned) diagnosticsPanelRef.current?.focus(); + }, [diagnosticsVisible, diagnosticsPositioned]); const testIdPrefix = variant === 'modal' ? 'global-search' : 'embedded-global-search'; const providers = useSyncExternalStore( globalSearchRegistry.subscribe, @@ -376,13 +396,20 @@ export const GlobalSearchContent: React.FC = ({ focusSearchInput(); }, [focusSearchInput]); + useEffect(() => { + if (!active || snapshot.diagnostics.length === 0) { + setDiagnosticsOpen(false); + } + }, [active, snapshot.diagnostics.length]); + const handleRootKeyDownCapture = useCallback((event: React.KeyboardEvent) => { - if (isImeOwnedKeyboardEvent(event, inputCompositionActiveRef.current)) return; + // The diagnostics layer owns Escape before the underlying group or dialog. + if (diagnosticsVisible || isImeOwnedKeyboardEvent(event, inputCompositionActiveRef.current)) return; if (event.key !== 'Escape' || !drilldownGroup) return; event.preventDefault(); event.stopPropagation(); closeGroupDetails(); - }, [closeGroupDetails, drilldownGroup]); + }, [closeGroupDetails, diagnosticsVisible, drilldownGroup]); const effectiveScope = parsedQuery.scope; const emptyLabel = effectiveScope === 'content' && parsedQuery.query.length < 2 @@ -666,27 +693,73 @@ export const GlobalSearchContent: React.FC = ({ || snapshot.diagnostics.length > 0) ? (