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
34 changes: 29 additions & 5 deletions packages/web/src/components/nav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,24 @@ import {SidebarProvider} from '@proxy/ui/components/sidebar';
import {ThemeProvider} from '@proxy/ui/components/theme-provider';
import {TooltipProvider} from '@proxy/ui/components/tooltip';
import {AppSidebar} from '@/components/nav';
import {type Unread, UnreadContext} from '@/hooks/use-unread';
import type {Human} from '@/lib/orgs';
import {testHuman, testOrg} from '@/test-org';

afterEach(() => {
cleanup();
});

function renderSidebar(human: Human, entry = '/acme') {
// What is unread, as the provider around the human side would have it.
function unreadOf(ids: string[]): Unread {
return {
unreadIds: new Set(ids),
answers: null,
setViewing: () => undefined,
};
}

function renderSidebar(human: Human, entry = '/acme', unread = unreadOf([])) {
const router = createMemoryRouter(
[
{
Expand All @@ -31,7 +41,9 @@ function renderSidebar(human: Human, entry = '/acme') {
<ThemeProvider>
<TooltipProvider>
<SidebarProvider collapsed>
<RouterProvider router={router} />
<UnreadContext.Provider value={unread}>
<RouterProvider router={router} />
</UnreadContext.Provider>
</SidebarProvider>
</TooltipProvider>
</ThemeProvider>,
Expand Down Expand Up @@ -66,16 +78,28 @@ describe('AppSidebar', () => {
expect(settings.getAttribute('href')).toBe('/acme/settings');
});

test('shows Alerts under Activity with the harness experiment, but no Chat tab', async () => {
test('shows Chat beside Home, and Alerts under Activity, with the harness experiment', async () => {
renderSidebar(testHuman({...testOrg, experiments: ['harness']}));
await screen.findByRole('link', {name: 'Home'});

expect(groupLabels()).toEqual([
['Home'],
['Home', 'Chat'],
['Connections', 'Agent permissions'],
['Activity', 'Alerts'],
]);
expect(screen.queryByRole('link', {name: 'Chat'})).toBeNull();
});

test('counts the conversations with something unread on Chat', async () => {
renderSidebar(
testHuman({...testOrg, experiments: ['harness']}),
'/acme',
unreadOf(['general', 'finance']),
);
await screen.findByRole('link', {name: 'Chat'});

const badge = screen.getByRole('status', {name: '2 unread'});
expect(badge.textContent).toBe('2');
expect(badge.closest('li')?.querySelector('a')?.textContent).toBe('Chat');
});

test("lights the switcher on the organization's members and settings", async () => {
Expand Down
36 changes: 34 additions & 2 deletions packages/web/src/components/nav.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import {BellIcon, BotIcon, HomeIcon, PlugIcon, ScrollTextIcon, type LucideIcon} from 'lucide-react';
import {
BellIcon,
BotIcon,
HomeIcon,
MessageSquareIcon,
PlugIcon,
ScrollTextIcon,
type LucideIcon,
} from 'lucide-react';
import type {Experiment} from '@proxy/utils';
import {Link, useLocation} from 'react-router';
import {OrgSwitcher} from '@/components/org-switcher';
Expand All @@ -16,14 +24,34 @@ import {
useSidebar,
} from '@proxy/ui/components/sidebar';
import {useHuman} from '@/hooks/use-human';
import {useUnread} from '@/hooks/use-unread';

// `experiment` is the experiment a destination is behind, shown only with it on.
type Destination = {to: string; label: string; icon: LucideIcon; experiment?: Experiment};

const CHAT = '/chat';

// How many conversations have something unread, on Chat: over the icon's corner on the rail, at
// the row's end where the labels show.
function UnreadBadge({count}: {count: number}) {
return (
<span
role="status"
aria-label={`${count} unread`}
className="pointer-events-none absolute top-1/2 right-2 flex h-4 min-w-4 -translate-y-1/2 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-none font-medium text-primary-foreground group-data-[collapsible=icon]:top-0 group-data-[collapsible=icon]:right-0 group-data-[collapsible=icon]:translate-y-0"
>
{count}
</span>
);
}

// The organization's, under the switcher, each under its slug, in groups set apart from each
// other. Its members and settings are in the switcher.
const GROUPS: Destination[][] = [
[{to: '', label: 'Home', icon: HomeIcon}],
[
{to: '', label: 'Home', icon: HomeIcon},
{to: '/chat', label: 'Chat', icon: MessageSquareIcon, experiment: 'harness'},
],
[
{to: '/connections', label: 'Connections', icon: PlugIcon},
{to: '/agents', label: 'Agent permissions', icon: BotIcon},
Expand All @@ -48,6 +76,7 @@ export function AppSidebar() {
const {pathname} = useLocation();
const human = useHuman();
const experiments = useExperiments();
const unread = useUnread();
const {setOpenMobile} = useSidebar();
const home = `/${human?.org.slug ?? ''}`;
const groups = GROUPS.map((group) =>
Expand Down Expand Up @@ -82,6 +111,9 @@ export function AppSidebar() {
<destination.icon />
<span>{destination.label}</span>
</SidebarMenuButton>
{destination.to === CHAT && unread.unreadIds.size > 0 && (
<UnreadBadge count={unread.unreadIds.size} />
)}
</SidebarMenuItem>
))}
</SidebarMenu>
Expand Down
Loading