Skip to content
Open
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
5 changes: 4 additions & 1 deletion app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,10 @@ export function AppShellDesktop() {

return (
<SidebarSlotProvider>
<div className="relative h-screen flex flex-col overflow-hidden">
<div
data-app-focus-fallback
tabIndex={-1}
className="relative h-screen flex flex-col overflow-hidden">
<AppBackground />
<div className="relative z-10 flex-1 min-h-0 flex flex-col overflow-hidden">
{chromeless ? (
Expand Down
56 changes: 55 additions & 1 deletion app/src/components/notices/NoticeCenter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,43 @@ export default function NoticeCenter() {
const notices = useAppNotices();
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const closeRef = useRef<HTMLButtonElement | null>(null);
const wasOpenRef = useRef(false);
const previousFocusRef = useRef<HTMLElement | null>(null);
const containsFocusRef = useRef(false);

// Focus enters only when the user opens the non-modal panel. On close,
// return to the bell, or to a stable app control if the bell disappears.
useEffect(() => {
const visible = open && notices.length > 0;
if (visible && !wasOpenRef.current) {
closeRef.current?.focus();
} else if (!visible && wasOpenRef.current && containsFocusRef.current) {
// A focused notice item may unmount with a null relatedTarget, leaving
// the blur tracker stale. Preserve focus already moved to another app
// control rather than incorrectly restoring the notice trigger.
const active = document.activeElement;
const focusMovedElsewhere =
active instanceof HTMLElement &&
active !== document.body &&
active !== document.documentElement &&
!panelRef.current?.contains(active) &&
!triggerRef.current?.contains(active);
if (focusMovedElsewhere) {
wasOpenRef.current = false;
return;
}
const previous = previousFocusRef.current;
const target =
triggerRef.current ??
(previous?.isConnected
? previous
: document.querySelector<HTMLElement>('[data-app-focus-fallback]'));
Comment thread
AmirrezaFarnamTaheri marked this conversation as resolved.
Resolved
target?.focus();
}
wasOpenRef.current = visible;
}, [open, notices.length]);

// Close on Escape while the panel is open. A fixed overlay with no dismissal
// affordance beyond its own toggle is a trap for keyboard users.
Expand All @@ -121,7 +158,22 @@ export default function NoticeCenter() {
return (
<div
className="fixed bottom-2 right-2 z-50 flex flex-col items-end gap-2"
data-testid="notice-center">
data-testid="notice-center"
onFocusCapture={event => {
const previous = event.relatedTarget;
if (previous instanceof HTMLElement && !event.currentTarget.contains(previous)) {
previousFocusRef.current = previous;
}
containsFocusRef.current = true;
}}
onBlurCapture={event => {
if (
event.relatedTarget instanceof Node &&
!event.currentTarget.contains(event.relatedTarget)
) {
containsFocusRef.current = false;
Comment thread
AmirrezaFarnamTaheri marked this conversation as resolved.
Resolved
}
}}>
{open && (
<div
ref={panelRef}
Expand All @@ -135,6 +187,7 @@ export default function NoticeCenter() {
iconOnly
size="xs"
variant="tertiary"
ref={closeRef}
data-testid="notice-close"
analyticsId="notice-center-close"
aria-label={t('common.close')}
Expand All @@ -157,6 +210,7 @@ export default function NoticeCenter() {
<Button
iconOnly
variant="secondary"
ref={triggerRef}
data-testid="notice-trigger"
analyticsId="notice-center-toggle"
aria-label={t('notices.title')}
Expand Down
66 changes: 64 additions & 2 deletions app/src/components/notices/__tests__/NoticeCenter.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { configureStore } from '@reduxjs/toolkit';
import { render, screen, within } from '@testing-library/react';
import { act, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import userErrorsReducer, { reportUserError } from '../../../store/userErrorsSlice';
import userErrorsReducer, {
dismissUserError,
reportUserError,
} from '../../../store/userErrorsSlice';
import type { UserErrorDescriptor } from '../../../types/userError';
import NoticeCenter from '../NoticeCenter';

Expand Down Expand Up @@ -58,6 +61,9 @@ function renderCenter(descriptors: UserErrorDescriptor[] = []) {
const tree = () => (
<Provider store={store}>
<MemoryRouter initialEntries={['/chat']}>
<div data-app-focus-fallback tabIndex={-1} data-testid="notice-fallback">
<button type="button">Resume work</button>
</div>
<NoticeCenter />
<LocationProbe />
</MemoryRouter>
Expand Down Expand Up @@ -105,6 +111,62 @@ describe('NoticeCenter', () => {
expect(within(panel).getByText('API key required')).toBeInTheDocument();
});

it('focuses the close control when opened and restores focus to the bell on close', async () => {
renderCenter([keyError]);
const trigger = screen.getByTestId('notice-trigger');

await userEvent.click(trigger);
expect(screen.getByTestId('notice-close')).toHaveFocus();

await userEvent.click(screen.getByTestId('notice-close'));
expect(screen.queryByTestId('notice-panel')).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});

it('restores app focus after dismissing the last notice', async () => {
renderCenter([keyError]);
const previous = screen.getByRole('button', { name: 'Resume work' });
previous.focus();
await userEvent.click(screen.getByTestId('notice-trigger'));
await userEvent.click(screen.getByTestId('notice-dismiss'));
expect(screen.queryByTestId('notice-center')).not.toBeInTheDocument();
expect(previous).toHaveFocus();
});

it('falls back to persistent app content if the previous control was unmounted', async () => {
renderCenter([keyError]);
const previous = screen.getByRole('button', { name: 'Resume work' });
previous.focus();
await userEvent.click(screen.getByTestId('notice-trigger'));
previous.remove();
await userEvent.click(screen.getByTestId('notice-dismiss'));
expect(screen.getByTestId('notice-fallback')).toHaveFocus();
});

it('preserves outside focus when notices disappear', async () => {
const { store } = renderCenter([keyError]);
await userEvent.click(screen.getByTestId('notice-trigger'));
const previous = screen.getByRole('button', { name: 'Resume work' });
previous.focus();
act(() => store.dispatch(dismissUserError({ id: keyError.id })));
expect(previous).toHaveFocus();
});

it('does not steal focus after a focused notice disappears but another remains', async () => {
const { store } = renderCenter([keyError, modelWarning]);
await userEvent.click(screen.getByTestId('notice-trigger'));
const dismiss = screen.getAllByTestId('notice-dismiss')[0];
dismiss.focus();
act(() => store.dispatch(dismissUserError({ id: keyError.id })));

const outside = screen.getByRole('button', { name: 'Resume work' });
outside.focus();
act(() => store.dispatch(dismissUserError({ id: modelWarning.id })));

expect(screen.queryByTestId('notice-center')).not.toBeInTheDocument();
expect(outside).toHaveFocus();
});

it('raises the usage limit as a notice that cannot be dismissed at the limit', async () => {
usageState.teamUsage = { plan: 'free' };
usageState.isAtLimit = true;
Expand Down
Loading