Goose Simple Compact UX (#4202)

Co-authored-by: David Katz <dkatz@squareup.com>
This commit is contained in:
Alex Hancock
2025-08-26 18:12:02 -04:00
committed by GitHub
parent a3097f5250
commit a4fc5ec4f1
27 changed files with 2608 additions and 1127 deletions
+12 -5
View File
@@ -12,6 +12,7 @@ const alertIcons: Record<AlertType, React.ReactNode> = {
interface AlertBoxProps {
alert: Alert;
className?: string;
compactButtonEnabled?: boolean;
}
const alertStyles: Record<AlertType, string> = {
@@ -60,17 +61,23 @@ export const AlertBox = ({ alert, className }: AlertBoxProps) => {
: alert.progress!.total}
</span>
</div>
{alert.showSummarizeButton && alert.onSummarize && (
{alert.showCompactButton && alert.onCompact && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
alert.onSummarize!();
alert.onCompact!();
}}
className="flex items-center gap-1.5 text-[11px] hover:opacity-80 cursor-pointer outline-none mt-1"
disabled={alert.compactButtonDisabled}
className={cn(
'flex items-center gap-1.5 text-[11px] outline-none mt-1',
alert.compactButtonDisabled
? 'opacity-50 cursor-not-allowed'
: 'hover:opacity-80 cursor-pointer'
)}
>
{alert.summarizeIcon}
<span>Summarize now</span>
{alert.compactIcon}
<span>Compact now</span>
</button>
)}
</div>
@@ -0,0 +1,327 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AlertBox } from '../AlertBox';
import { Alert, AlertType } from '../types';
describe('AlertBox', () => {
const mockOnCompact = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe('Basic Rendering', () => {
it('should render info alert with message', () => {
const alert: Alert = {
type: AlertType.Info,
message: 'Test info message',
};
render(<AlertBox alert={alert} />);
expect(screen.getByText('Test info message')).toBeInTheDocument();
});
it('should render warning alert with correct styling', () => {
const alert: Alert = {
type: AlertType.Warning,
message: 'Test warning message',
};
const { container } = render(<AlertBox alert={alert} />);
const alertElement = container.querySelector('.bg-\\[\\#cc4b03\\]');
expect(alertElement).toBeInTheDocument();
expect(screen.getByText('Test warning message')).toBeInTheDocument();
});
it('should render error alert with correct styling', () => {
const alert: Alert = {
type: AlertType.Error,
message: 'Test error message',
};
const { container } = render(<AlertBox alert={alert} />);
const alertElement = container.querySelector('.bg-\\[\\#d7040e\\]');
expect(alertElement).toBeInTheDocument();
expect(screen.getByText('Test error message')).toBeInTheDocument();
});
it('should apply custom className', () => {
const alert: Alert = {
type: AlertType.Info,
message: 'Test message',
};
const { container } = render(<AlertBox alert={alert} className="custom-class" />);
const alertElement = container.firstChild as HTMLElement;
expect(alertElement).toHaveClass('custom-class');
});
});
describe('Progress Bar', () => {
it('should render progress bar when progress is provided', () => {
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
progress: {
current: 50,
total: 100,
},
};
render(<AlertBox alert={alert} />);
expect(screen.getByText('50')).toBeInTheDocument();
expect(screen.getByText('50%')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
// Check progress bar exists
const progressDots = screen.getByText('Context window').parentElement?.parentElement?.querySelectorAll('.h-\\[2px\\]');
expect(progressDots).toBeDefined();
});
it('should handle zero current value', () => {
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
progress: {
current: 0,
total: 100,
},
};
render(<AlertBox alert={alert} />);
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('0%')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
});
it('should handle 100% progress', () => {
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
progress: {
current: 100,
total: 100,
},
};
render(<AlertBox alert={alert} />);
// Use getAllByText since there are multiple "100" elements (current and total)
const hundredElements = screen.getAllByText('100');
expect(hundredElements).toHaveLength(2); // One for current, one for total
expect(screen.getByText('100%')).toBeInTheDocument();
});
it('should format large numbers with k suffix', () => {
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
progress: {
current: 1500,
total: 10000,
},
};
render(<AlertBox alert={alert} />);
expect(screen.getByText('1.5k')).toBeInTheDocument();
expect(screen.getByText('15%')).toBeInTheDocument();
expect(screen.getByText('10k')).toBeInTheDocument();
});
it('should handle progress over 100%', () => {
const alert: Alert = {
type: AlertType.Warning,
message: 'Context window',
progress: {
current: 150,
total: 100,
},
};
render(<AlertBox alert={alert} />);
expect(screen.getByText('150')).toBeInTheDocument();
expect(screen.getByText('150%')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
});
});
describe('Compact Button', () => {
it('should render compact button when showCompactButton is true', () => {
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
progress: { current: 50, total: 100 },
showCompactButton: true,
onCompact: mockOnCompact,
};
render(<AlertBox alert={alert} />);
expect(screen.getByText('Compact now')).toBeInTheDocument();
});
it('should render compact button with custom icon', () => {
const CompactIcon = () => <span data-testid="compact-icon">📦</span>;
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
progress: { current: 50, total: 100 },
showCompactButton: true,
onCompact: mockOnCompact,
compactIcon: <CompactIcon />,
};
render(<AlertBox alert={alert} />);
expect(screen.getByTestId('compact-icon')).toBeInTheDocument();
expect(screen.getByText('Compact now')).toBeInTheDocument();
});
it('should call onCompact when compact button is clicked', async () => {
const user = userEvent.setup();
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
progress: { current: 50, total: 100 },
showCompactButton: true,
onCompact: mockOnCompact,
};
render(<AlertBox alert={alert} />);
const compactButton = screen.getByText('Compact now');
await user.click(compactButton);
expect(mockOnCompact).toHaveBeenCalledTimes(1);
});
it('should prevent event propagation when compact button is clicked', () => {
const mockParentClick = vi.fn();
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
progress: { current: 50, total: 100 },
showCompactButton: true,
onCompact: mockOnCompact,
};
render(
<div onClick={mockParentClick}>
<AlertBox alert={alert} />
</div>
);
const compactButton = screen.getByText('Compact now');
fireEvent.click(compactButton);
expect(mockOnCompact).toHaveBeenCalledTimes(1);
expect(mockParentClick).not.toHaveBeenCalled();
});
it('should not render compact button when showCompactButton is false', () => {
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
progress: { current: 50, total: 100 },
showCompactButton: false,
onCompact: mockOnCompact,
};
render(<AlertBox alert={alert} />);
expect(screen.queryByText('Compact now')).not.toBeInTheDocument();
});
it('should not render compact button when onCompact is not provided', () => {
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
progress: { current: 50, total: 100 },
showCompactButton: true,
};
render(<AlertBox alert={alert} />);
expect(screen.queryByText('Compact now')).not.toBeInTheDocument();
});
});
describe('Combined Features', () => {
it('should render progress bar and compact button together', () => {
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
progress: {
current: 75,
total: 100,
},
showCompactButton: true,
onCompact: mockOnCompact,
};
render(<AlertBox alert={alert} />);
expect(screen.getByText('75')).toBeInTheDocument();
expect(screen.getByText('75%')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
expect(screen.getByText('Compact now')).toBeInTheDocument();
});
it('should handle multiline messages', () => {
const alert: Alert = {
type: AlertType.Warning,
message: 'Line 1\nLine 2\nLine 3',
};
render(<AlertBox alert={alert} />);
// Use a function matcher to handle the whitespace-pre-line rendering
expect(screen.getByText((content) => content.includes('Line 1') && content.includes('Line 2') && content.includes('Line 3'))).toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle empty message', () => {
const alert: Alert = {
type: AlertType.Info,
message: '',
};
const { container } = render(<AlertBox alert={alert} />);
// Should still render the alert container
const alertElement = container.querySelector('.flex.flex-col.gap-2');
expect(alertElement).toBeInTheDocument();
});
it('should handle progress with zero total', () => {
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
progress: {
current: 10,
total: 0,
},
};
render(<AlertBox alert={alert} />);
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
// Progress percentage would be Infinity, but it should still render
expect(screen.getByText('Infinity%')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,339 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAlerts } from '../useAlerts';
import { AlertType } from '../types';
describe('useAlerts', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Initial State', () => {
it('should start with empty alerts array', () => {
const { result } = renderHook(() => useAlerts());
expect(result.current.alerts).toEqual([]);
expect(typeof result.current.addAlert).toBe('function');
expect(typeof result.current.clearAlerts).toBe('function');
});
});
describe('Adding Alerts', () => {
it('should add a single alert', () => {
const { result } = renderHook(() => useAlerts());
const newAlert = {
type: AlertType.Info,
message: 'Test alert',
};
act(() => {
result.current.addAlert(newAlert);
});
expect(result.current.alerts).toHaveLength(1);
expect(result.current.alerts[0]).toMatchObject(newAlert);
});
it('should add multiple alerts', () => {
const { result } = renderHook(() => useAlerts());
const alert1 = { type: AlertType.Info, message: 'First alert' };
const alert2 = { type: AlertType.Warning, message: 'Second alert' };
const alert3 = { type: AlertType.Error, message: 'Third alert' };
act(() => {
result.current.addAlert(alert1);
result.current.addAlert(alert2);
result.current.addAlert(alert3);
});
expect(result.current.alerts).toHaveLength(3);
expect(result.current.alerts[0]).toMatchObject(alert1);
expect(result.current.alerts[1]).toMatchObject(alert2);
expect(result.current.alerts[2]).toMatchObject(alert3);
});
it('should add alerts with all optional properties', () => {
const { result } = renderHook(() => useAlerts());
const complexAlert = {
type: AlertType.Info,
message: 'Complex alert',
progress: { current: 50, total: 100 },
showCompactButton: true,
onCompact: vi.fn(),
compactIcon: <span>Icon</span>,
autoShow: true,
};
act(() => {
result.current.addAlert(complexAlert);
});
expect(result.current.alerts).toHaveLength(1);
expect(result.current.alerts[0]).toMatchObject(complexAlert);
});
});
describe('Clearing Alerts', () => {
it('should clear all alerts', () => {
const { result } = renderHook(() => useAlerts());
// Add some alerts first
act(() => {
result.current.addAlert({ type: AlertType.Info, message: 'Alert 1' });
result.current.addAlert({ type: AlertType.Warning, message: 'Alert 2' });
result.current.addAlert({ type: AlertType.Error, message: 'Alert 3' });
});
expect(result.current.alerts).toHaveLength(3);
// Clear all alerts
act(() => {
result.current.clearAlerts();
});
expect(result.current.alerts).toHaveLength(0);
expect(result.current.alerts).toEqual([]);
});
it('should handle clearing when no alerts exist', () => {
const { result } = renderHook(() => useAlerts());
expect(result.current.alerts).toHaveLength(0);
// Should not throw error
act(() => {
result.current.clearAlerts();
});
expect(result.current.alerts).toHaveLength(0);
});
});
describe('Alert Management Patterns', () => {
it('should handle rapid add and clear operations', () => {
const { result } = renderHook(() => useAlerts());
// Rapid operations
act(() => {
result.current.addAlert({ type: AlertType.Info, message: 'Alert 1' });
result.current.addAlert({ type: AlertType.Warning, message: 'Alert 2' });
result.current.clearAlerts();
result.current.addAlert({ type: AlertType.Error, message: 'Alert 3' });
});
expect(result.current.alerts).toHaveLength(1);
expect(result.current.alerts[0].message).toBe('Alert 3');
});
it('should maintain alert order', () => {
const { result } = renderHook(() => useAlerts());
const alerts = [
{ type: AlertType.Info, message: 'First' },
{ type: AlertType.Warning, message: 'Second' },
{ type: AlertType.Error, message: 'Third' },
{ type: AlertType.Info, message: 'Fourth' },
];
act(() => {
alerts.forEach(alert => result.current.addAlert(alert));
});
expect(result.current.alerts).toHaveLength(4);
alerts.forEach((alert, index) => {
expect(result.current.alerts[index].message).toBe(alert.message);
});
});
it('should handle duplicate alerts', () => {
const { result } = renderHook(() => useAlerts());
const duplicateAlert = { type: AlertType.Info, message: 'Duplicate alert' };
act(() => {
result.current.addAlert(duplicateAlert);
result.current.addAlert(duplicateAlert);
result.current.addAlert(duplicateAlert);
});
// Should allow duplicates
expect(result.current.alerts).toHaveLength(3);
result.current.alerts.forEach(alert => {
expect(alert.message).toBe('Duplicate alert');
});
});
});
describe('Alert Types', () => {
it('should handle all alert types', () => {
const { result } = renderHook(() => useAlerts());
const alertTypes = [
{ type: AlertType.Info, message: 'Info alert' },
{ type: AlertType.Warning, message: 'Warning alert' },
{ type: AlertType.Error, message: 'Error alert' },
];
act(() => {
alertTypes.forEach(alert => result.current.addAlert(alert));
});
expect(result.current.alerts).toHaveLength(3);
expect(result.current.alerts[0].type).toBe(AlertType.Info);
expect(result.current.alerts[1].type).toBe(AlertType.Warning);
expect(result.current.alerts[2].type).toBe(AlertType.Error);
});
});
describe('Progress Alerts', () => {
it('should handle alerts with progress', () => {
const { result } = renderHook(() => useAlerts());
const progressAlert = {
type: AlertType.Info,
message: 'Loading...',
progress: { current: 25, total: 100 },
};
act(() => {
result.current.addAlert(progressAlert);
});
expect(result.current.alerts[0].progress).toEqual({ current: 25, total: 100 });
});
it('should handle progress updates by replacing alerts', () => {
const { result } = renderHook(() => useAlerts());
// Add initial progress alert
act(() => {
result.current.addAlert({
type: AlertType.Info,
message: 'Loading...',
progress: { current: 25, total: 100 },
});
});
expect(result.current.alerts[0].progress?.current).toBe(25);
// Clear and add updated progress
act(() => {
result.current.clearAlerts();
result.current.addAlert({
type: AlertType.Info,
message: 'Loading...',
progress: { current: 75, total: 100 },
});
});
expect(result.current.alerts).toHaveLength(1);
expect(result.current.alerts[0].progress?.current).toBe(75);
});
});
describe('Compact Button Alerts', () => {
it('should handle alerts with compact functionality', () => {
const { result } = renderHook(() => useAlerts());
const mockOnCompact = vi.fn();
const compactAlert = {
type: AlertType.Info,
message: 'Context window full',
showCompactButton: true,
onCompact: mockOnCompact,
compactIcon: <span>📦</span>,
};
act(() => {
result.current.addAlert(compactAlert);
});
const alert = result.current.alerts[0];
expect(alert.showCompactButton).toBe(true);
expect(alert.onCompact).toBe(mockOnCompact);
expect(alert.compactIcon).toBeDefined();
});
});
describe('Auto-show Alerts', () => {
it('should handle autoShow property', () => {
const { result } = renderHook(() => useAlerts());
const autoShowAlert = {
type: AlertType.Error,
message: 'Critical error',
autoShow: true,
};
act(() => {
result.current.addAlert(autoShowAlert);
});
expect(result.current.alerts[0].autoShow).toBe(true);
});
it('should handle alerts without autoShow property', () => {
const { result } = renderHook(() => useAlerts());
const regularAlert = {
type: AlertType.Info,
message: 'Regular alert',
};
act(() => {
result.current.addAlert(regularAlert);
});
expect(result.current.alerts[0].autoShow).toBeUndefined();
});
});
describe('Edge Cases', () => {
it('should handle empty message', () => {
const { result } = renderHook(() => useAlerts());
act(() => {
result.current.addAlert({
type: AlertType.Info,
message: '',
});
});
expect(result.current.alerts).toHaveLength(1);
expect(result.current.alerts[0].message).toBe('');
});
it('should handle very long messages', () => {
const { result } = renderHook(() => useAlerts());
const longMessage = 'A'.repeat(1000);
act(() => {
result.current.addAlert({
type: AlertType.Info,
message: longMessage,
});
});
expect(result.current.alerts[0].message).toBe(longMessage);
});
it('should handle special characters in messages', () => {
const { result } = renderHook(() => useAlerts());
const specialMessage = '🚨 Alert with émojis and spëcial chars! @#$%^&*()';
act(() => {
result.current.addAlert({
type: AlertType.Warning,
message: specialMessage,
});
});
expect(result.current.alerts[0].message).toBe(specialMessage);
});
});
});
+4 -3
View File
@@ -16,7 +16,8 @@ export interface Alert {
current: number;
total: number;
};
showSummarizeButton?: boolean;
onSummarize?: () => void;
summarizeIcon?: React.ReactNode;
showCompactButton?: boolean;
compactButtonDisabled?: boolean;
onCompact?: () => void;
compactIcon?: React.ReactNode;
}