From 8f1d9003b329dd45ff9898a098405f3aae887c03 Mon Sep 17 00:00:00 2001 From: Raing5Days Date: Fri, 30 Oct 2026 20:41:15 +0800 Subject: [PATCH 1/3] fix(search): format diagnostic panel as a popover to prevent footer overlap When search source diagnostics are expanded in the footer, the content previously overflowed and vertically centered over the healthy results due to footer flex sizing and lack of an elevated background. Render the diagnostics as an absolute popover panel positioned above the footer summary, styled with raised background, subtle border and overlay shadow. Add escape and click-outside handling to close the panel cleanly. Fixes #3628 --- .../GlobalSearchContent.test.tsx | 39 +++++++ .../app/global-search/GlobalSearchRoot.scss | 104 ++++++++++++++++-- .../app/global-search/GlobalSearchRoot.tsx | 91 ++++++++++----- 3 files changed, 202 insertions(+), 32 deletions(-) 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..cd7937aa62 100644 --- a/src/web-ui/src/app/global-search/GlobalSearchContent.test.tsx +++ b/src/web-ui/src/app/global-search/GlobalSearchContent.test.tsx @@ -104,6 +104,45 @@ describe('GlobalSearchContent accessibility', () => { expect(details.querySelector('button')?.disabled).toBe(true); }); + it('toggles diagnostic panel and closes it on Escape or click outside', 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)); + act(() => root.render()); + await act(async () => { await vi.runOnlyPendingTimersAsync(); }); + + const details = container.querySelector('details')!; + const summary = details.querySelector('summary')!; + expect(details.hasAttribute('open')).toBe(false); + expect(details.querySelector('.global-search__diagnostics-panel')).not.toBeNull(); + + // Toggle open + act(() => { + details.open = true; + details.dispatchEvent(new Event('toggle')); + }); + expect(details.open).toBe(true); + + // Escape closes diagnostics panel + act(() => { + container.querySelector('.global-search')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + expect(details.open).toBe(false); + + // Reopen and click outside closes diagnostics panel + act(() => { + details.open = true; + details.dispatchEvent(new Event('toggle')); + }); + expect(details.open).toBe(true); + act(() => { + document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); + }); + expect(details.open).toBe(false); + }); + it('discards old results and ignores late completions after a device switch', async () => { act(() => root.render()); await act(async () => { await vi.runOnlyPendingTimersAsync(); }); diff --git a/src/web-ui/src/app/global-search/GlobalSearchRoot.scss b/src/web-ui/src/app/global-search/GlobalSearchRoot.scss index e448388e10..d9fd4eb21b 100644 --- a/src/web-ui/src/app/global-search/GlobalSearchRoot.scss +++ b/src/web-ui/src/app/global-search/GlobalSearchRoot.scss @@ -340,6 +340,8 @@ $chevron-box-slack: 5px; } &__footer { + position: relative; + z-index: 5; display: flex; flex: 0 0 40px; align-items: center; @@ -890,17 +892,105 @@ $chevron-box-slack: 5px; } .global-search__diagnostics { + position: relative; min-width: 0; - flex: 1; - max-height: 200px; - overflow: auto; - summary { cursor: pointer; } + margin-right: auto; + color: var(--openbitfun-color-content-muted); + + summary { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + user-select: none; + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-meta-font-size); + border-radius: var(--openbitfun-radius-sm); + padding: 2px 6px; + margin-left: -6px; + list-style: none; + transition: background-color var(--openbitfun-motion-duration-fast, 150ms) ease, + color var(--openbitfun-motion-duration-fast, 150ms) ease; + + &::-webkit-details-marker { + display: none; + } + + &:hover { + background: var(--openbitfun-color-action-quiet-hover); + color: var(--openbitfun-color-content-primary); + } + + &:focus-visible { + outline: 2px solid var(--openbitfun-color-accent-default); + outline-offset: 1px; + } + } + + &[open] summary { + 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__diagnostic-target { margin-block: 8px; } + +.global-search__diagnostics-panel { + position: absolute; + bottom: calc(100% + 8px); + left: 0; + z-index: 30; + box-sizing: border-box; + width: max-content; + min-width: 280px; + max-width: min(480px, calc(100vw - 36px)); + max-height: 240px; + 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-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 { 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, 600); + } + + p { + margin-block: 4px 8px; + color: var(--openbitfun-color-content-secondary); + line-height: var(--openbitfun-type-body-line-height, 1.4); + } +} + +.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-font-family-mono, monospace); + font-size: var(--openbitfun-type-micro-font-size, 11px); + 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..ca32d2c0be 100644 --- a/src/web-ui/src/app/global-search/GlobalSearchRoot.tsx +++ b/src/web-ui/src/app/global-search/GlobalSearchRoot.tsx @@ -20,7 +20,7 @@ import { OverflowText, Dialog, DialogBody, } 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'; @@ -183,6 +183,8 @@ 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 diagnosticsRef = useRef(null); const inputRef = useRef(null); const inputCompositionActiveRef = useRef(false); const listRef = useRef(null); @@ -376,13 +378,39 @@ export const GlobalSearchContent: React.FC = ({ focusSearchInput(); }, [focusSearchInput]); + useEffect(() => { + if (snapshot.diagnostics.length === 0) { + setDiagnosticsOpen(false); + } + }, [snapshot.diagnostics.length]); + + useEffect(() => { + if (!diagnosticsOpen) return; + const handlePointerDownOutside = (event: PointerEvent) => { + if (diagnosticsRef.current && !diagnosticsRef.current.contains(event.target as Node)) { + setDiagnosticsOpen(false); + } + }; + document.addEventListener('pointerdown', handlePointerDownOutside); + return () => document.removeEventListener('pointerdown', handlePointerDownOutside); + }, [diagnosticsOpen]); + const handleRootKeyDownCapture = useCallback((event: React.KeyboardEvent) => { if (isImeOwnedKeyboardEvent(event, inputCompositionActiveRef.current)) return; - if (event.key !== 'Escape' || !drilldownGroup) return; - event.preventDefault(); - event.stopPropagation(); - closeGroupDetails(); - }, [closeGroupDetails, drilldownGroup]); + if (event.key === 'Escape') { + if (diagnosticsOpen) { + event.preventDefault(); + event.stopPropagation(); + setDiagnosticsOpen(false); + return; + } + if (drilldownGroup) { + event.preventDefault(); + event.stopPropagation(); + closeGroupDetails(); + } + } + }, [closeGroupDetails, diagnosticsOpen, drilldownGroup]); const effectiveScope = parsedQuery.scope; const emptyLabel = effectiveScope === 'content' && parsedQuery.query.length < 2 @@ -666,26 +694,39 @@ export const GlobalSearchContent: React.FC = ({ || snapshot.diagnostics.length > 0) ? (