Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/web-ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
181 changes: 168 additions & 13 deletions src/web-ui/src/app/global-search/GlobalSearchContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -43,25 +44,50 @@ 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<HTMLElement>('.global-search__diagnostics-panel');
const diagnosticTrigger = () => document.querySelector<HTMLButtonElement>('[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 ? <GlobalSearchRoot /> : <GlobalSearchContent initialQuery="needle" />));
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);
});
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 () => {
Expand All @@ -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(<GlobalSearchContent initialQuery="needle" />));
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<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLElement>('[data-testid="global-search-dialog"]')!;
const target = focus === 'panel' ? panel : dialog.querySelector<HTMLInputElement>('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<HTMLButtonElement>('button')!;
act(() => {
retry.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
retry.focus();
});
expect(diagnosticPanel()).toBe(panel);
expect(document.activeElement).toBe(retry);
const input = document.querySelector<HTMLInputElement>('[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(<GlobalSearchRoot />));
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
act(() => document.querySelector<HTMLButtonElement>('[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 () => {
Expand Down
75 changes: 68 additions & 7 deletions src/web-ui/src/app/global-search/GlobalSearchRoot.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Loading
Loading