diff --git a/packages/docs/content/connections/vault.mdx b/packages/docs/content/connections/vault.mdx
index 8374fa18..452ab78c 100644
--- a/packages/docs/content/connections/vault.mdx
+++ b/packages/docs/content/connections/vault.mdx
@@ -20,9 +20,9 @@ written.
### Add an entry
-Open **Vault** in the sidebar and choose **Add entry**. Once anyone else is in the organization, or
-invited to it, **Just me** and **Everyone in** the organization pick which vault is open, and a new
-entry goes in that one.
+Open **Connections** in the sidebar, then **Vault**, first in the list, and choose **Add entry**.
+Once anyone else is in the organization, or invited to it, **Just me** and **Everyone in** the
+organization pick which vault is open, and a new entry goes in that one.
### Choose what agents can do
diff --git a/packages/docs/content/organizations.mdx b/packages/docs/content/organizations.mdx
index 11d80d99..ce3bdfe9 100644
--- a/packages/docs/content/organizations.mdx
+++ b/packages/docs/content/organizations.mdx
@@ -8,8 +8,8 @@ with everyone in it or one member's own, its activity log, its vault and each me
starts with one of their own, and can make more, such as one for work beside a personal one, or
join someone else's. Every page is one organization's, named first in its address
(`/acme/connections`), so a link to a page is a link to it in that organization; the mark at the
-top of the sidebar is the one you are in: hover it to switch, or to create another. Your agents
-there reach what is connected there.
+top of the sidebar is the one you are in: hover it to switch, to open its **Members** or
+**Settings**, or to create another. Your agents there reach what is connected there.
## Your own or everyone's
@@ -56,9 +56,10 @@ page on a shared connection is logged with their name.
## Members
-**Members** lists who is in the organization and who is invited. Every organization has one owner,
-whoever made it, for good; everyone else is a member. The owner invites people by email, removes
-members, renames the organization and deletes it. Members use everything in it and can leave.
+**Members**, under the organization's mark at the top of the sidebar, lists who is in the
+organization and who is invited. Every organization has one owner, whoever made it, for good;
+everyone else is a member. The owner invites people by email, removes members, renames the
+organization and deletes it. Members use everything in it and can leave.
An invitation is a link that works for a week, for the address it was sent to: the person signs in,
or creates an account, as that address to join. Inviting follows the same rules as signing up, so
diff --git a/packages/ui/src/components/separator.tsx b/packages/ui/src/components/separator.tsx
deleted file mode 100644
index e0bb631d..00000000
--- a/packages/ui/src/components/separator.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import {Separator as SeparatorPrimitive} from '@base-ui/react/separator';
-import {cn} from 'cn';
-
-function Separator({className, orientation = 'horizontal', ...props}: SeparatorPrimitive.Props) {
- return (
-
- );
-}
-
-export {Separator};
diff --git a/packages/ui/src/components/sidebar.tsx b/packages/ui/src/components/sidebar.tsx
index 56f081de..5c97496b 100644
--- a/packages/ui/src/components/sidebar.tsx
+++ b/packages/ui/src/components/sidebar.tsx
@@ -8,8 +8,6 @@ import {cn} from 'cn';
import {useIsMobile} from '@proxy/ui/hooks/use-mobile';
import {Button} from '@proxy/ui/components/button';
-import {Input} from '@proxy/ui/components/input';
-import {Separator} from '@proxy/ui/components/separator';
import {
Sheet,
SheetContent,
@@ -17,25 +15,18 @@ import {
SheetHeader,
SheetTitle,
} from '@proxy/ui/components/sheet';
-import {Skeleton} from '@proxy/ui/components/skeleton';
import {Tooltip, TooltipContent, TooltipTrigger} from '@proxy/ui/components/tooltip';
import {PanelLeftIcon} from 'lucide-react';
-const SIDEBAR_COOKIE_NAME = 'sidebar_state';
-const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = '16rem';
const SIDEBAR_WIDTH_MOBILE = '18rem';
const SIDEBAR_WIDTH_ICON = '3rem';
-const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
type SidebarContextProps = {
state: 'expanded' | 'collapsed';
- open: boolean;
- setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
- toggleSidebar: () => void;
};
const SidebarContext = React.createContext(null);
@@ -49,74 +40,24 @@ function useSidebar() {
return context;
}
+// On desktop the sidebar is either always collapsed or always expanded, as `collapsed` says: there
+// is no toggling it. On phones it is a sheet, opened by the trigger and closed on every pick.
function SidebarProvider({
- defaultOpen = true,
- open: openProp,
- onOpenChange: setOpenProp,
+ collapsed = false,
className,
style,
children,
...props
}: React.ComponentProps<'div'> & {
- defaultOpen?: boolean;
- open?: boolean;
- onOpenChange?: (open: boolean) => void;
+ collapsed?: boolean;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
-
- // This is the internal state of the sidebar.
- // We use openProp and setOpenProp for control from outside the component.
- const [_open, _setOpen] = React.useState(defaultOpen);
- const open = openProp ?? _open;
- const setOpen = React.useCallback(
- (value: boolean | ((value: boolean) => boolean)) => {
- const openState = typeof value === 'function' ? value(open) : value;
- if (setOpenProp) {
- setOpenProp(openState);
- } else {
- _setOpen(openState);
- }
-
- // This sets the cookie to keep the sidebar state.
- document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
- },
- [setOpenProp, open],
- );
-
- // Helper to toggle the sidebar.
- const toggleSidebar = React.useCallback(() => {
- return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
- }, [isMobile, setOpen, setOpenMobile]);
-
- // Adds a keyboard shortcut to toggle the sidebar.
- React.useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
- event.preventDefault();
- toggleSidebar();
- }
- };
-
- window.addEventListener('keydown', handleKeyDown);
- return () => window.removeEventListener('keydown', handleKeyDown);
- }, [toggleSidebar]);
-
- // We add a state so that we can do data-state="expanded" or "collapsed".
- // This makes it easier to style the sidebar with Tailwind classes.
- const state = open ? 'expanded' : 'collapsed';
+ const state = collapsed ? 'collapsed' : 'expanded';
const contextValue = React.useMemo(
- () => ({
- state,
- open,
- setOpen,
- isMobile,
- openMobile,
- setOpenMobile,
- toggleSidebar,
- }),
- [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
+ () => ({state, isMobile, openMobile, setOpenMobile}),
+ [state, isMobile, openMobile],
);
return (
@@ -244,8 +185,9 @@ function Sidebar({
);
}
+// Opens the sidebar's sheet on phones.
function SidebarTrigger({className, onClick, ...props}: React.ComponentProps) {
- const {toggleSidebar} = useSidebar();
+ const {openMobile, setOpenMobile} = useSidebar();
return (
);
}
-function SidebarRail({className, ...props}: React.ComponentProps<'button'>) {
- const {toggleSidebar} = useSidebar();
-
- return (
-
- );
-}
-
-function SidebarInset({className, ...props}: React.ComponentProps<'main'>) {
- return (
-
- );
-}
-
-function SidebarInput({className, ...props}: React.ComponentProps) {
- return (
-
- );
-}
-
function SidebarHeader({className, ...props}: React.ComponentProps<'div'>) {
return (
+
);
diff --git a/packages/web/src/components/nav.test.tsx b/packages/web/src/components/nav.test.tsx
index 3a883db3..f048d687 100644
--- a/packages/web/src/components/nav.test.tsx
+++ b/packages/web/src/components/nav.test.tsx
@@ -1,19 +1,86 @@
///
import {afterEach, describe, expect, test} from 'bun:test';
-import {cleanup, render, screen} from '@testing-library/react';
+import {cleanup, fireEvent, render, screen, within} from '@testing-library/react';
import {createMemoryRouter, RouterProvider} from 'react-router';
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 {testHuman} from '@/test-org';
+import type {Human} from '@/lib/orgs';
+import {testHuman, testOrg} from '@/test-org';
afterEach(() => {
cleanup();
});
+function renderSidebar(human: Human, entry = '/acme') {
+ const router = createMemoryRouter(
+ [
+ {
+ id: 'human',
+ path: '/:org/*',
+ loader: () => human,
+ element: ,
+ },
+ ],
+ {initialEntries: [entry]},
+ );
+
+ return render(
+
+
+
+
+
+
+ ,
+ );
+}
+
+// The organization's pages in the sidebar, group by group.
+function groupLabels(): string[][] {
+ return [...document.querySelectorAll('[data-sidebar="content"] [data-sidebar="group"]')].map(
+ (group) =>
+ within(group as HTMLElement)
+ .getAllByRole('link')
+ .map((link) => link.textContent),
+ );
+}
+
describe('AppSidebar', () => {
+ test("groups the organization's pages, its members and settings in the switcher", async () => {
+ renderSidebar(testHuman());
+ const home = await screen.findByRole('link', {name: 'Home'});
+ expect(home.hasAttribute('data-active')).toBe(true);
+ expect(
+ screen.getByRole('button', {name: 'Organization: Acme'}).hasAttribute('data-active'),
+ ).toBe(false);
+
+ expect(groupLabels()).toEqual([['Home'], ['Connections', 'Agent permissions'], ['Activity']]);
+
+ fireEvent.click(screen.getByRole('button', {name: 'Organization: Acme'}));
+ const members = await screen.findByRole('menuitem', {name: 'Members'});
+ expect(members.getAttribute('href')).toBe('/acme/members');
+ const settings = screen.getByRole('menuitem', {name: 'Settings'});
+ expect(settings.getAttribute('href')).toBe('/acme/settings');
+ });
+
+ test('shows Chat beside Home with the harness experiment', async () => {
+ renderSidebar(testHuman({...testOrg, experiments: ['harness']}));
+ await screen.findByRole('link', {name: 'Home'});
+
+ expect(groupLabels()[0]).toEqual(['Home', 'Chat']);
+ });
+
+ test("lights the switcher on the organization's members and settings", async () => {
+ renderSidebar(testHuman(), '/acme/members');
+
+ const switcher = await screen.findByRole('button', {name: 'Organization: Acme'});
+ expect(switcher.hasAttribute('data-active')).toBe(true);
+ expect(screen.getByRole('link', {name: 'Home'}).hasAttribute('data-active')).toBe(false);
+ });
+
test('links the signed-in Google profile image to Account', async () => {
const human = testHuman();
human.user = {
@@ -21,27 +88,7 @@ describe('AppSidebar', () => {
name: 'Ada Lovelace',
image: 'https://example.com/ada.png',
};
- const router = createMemoryRouter(
- [
- {
- id: 'human',
- path: '/:org',
- loader: () => human,
- element: ,
- },
- ],
- {initialEntries: ['/acme']},
- );
-
- render(
-
-
-
-
-
-
- ,
- );
+ renderSidebar(human);
const account = await screen.findByRole('link', {name: 'Account'});
const avatar = account.querySelector('[data-slot="person-avatar"]');
diff --git a/packages/web/src/components/nav.tsx b/packages/web/src/components/nav.tsx
index bd051503..34eded83 100644
--- a/packages/web/src/components/nav.tsx
+++ b/packages/web/src/components/nav.tsx
@@ -1,13 +1,10 @@
import {
BotIcon,
HomeIcon,
- LibraryIcon,
LogOutIcon,
MessageSquareIcon,
PlugIcon,
ScrollTextIcon,
- SettingsIcon,
- UsersIcon,
type LucideIcon,
} from 'lucide-react';
import type {Experiment} from '@proxy/utils';
@@ -15,7 +12,6 @@ import {Link, useLocation} from 'react-router';
import {signOut} from '@/client/auth-client';
import {OrgSwitcher} from '@/components/org-switcher';
import {PersonAvatar} from '@/components/person-avatar';
-import {ThemeMenu} from '@/components/theme-menu';
import {useExperiments} from '@/hooks/use-experiment';
import {
Sidebar,
@@ -33,16 +29,18 @@ import {useHuman} from '@/hooks/use-human';
// `experiment` is the experiment a destination is behind, shown only with it on.
type Destination = {to: string; label: string; icon: LucideIcon; experiment?: Experiment};
-// The organization's, under the switcher, each under its slug.
-const DESTINATIONS: Destination[] = [
- {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},
- {to: '/vault', label: 'Vault', icon: LibraryIcon},
- {to: '/activity', label: 'Activity', icon: ScrollTextIcon},
- {to: '/members', label: 'Members', icon: UsersIcon},
- {to: '/settings', label: 'Settings', icon: SettingsIcon},
+// 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: '/chat', label: 'Chat', icon: MessageSquareIcon, experiment: 'harness'},
+ ],
+ [
+ {to: '/connections', label: 'Connections', icon: PlugIcon},
+ {to: '/agents', label: 'Agent permissions', icon: BotIcon},
+ ],
+ [{to: '/activity', label: 'Activity', icon: ScrollTextIcon}],
];
// Each destination is lit on its own pages too, as `/acme/connections/:id`; Home only on itself,
@@ -51,19 +49,21 @@ function isActive(pathname: string, path: string, home: string): boolean {
return path === home ? pathname === home : pathname.startsWith(path);
}
-// The stock shadcn sidebar: a rail in icon mode on desktop, labels in tooltips and ⌘B to expand
-// it; a sheet from the left on phones, opened from the header and closed again on every pick. The
-// organization switcher sits at its top, with the organization's pages under it; the person's own,
-// their account, theme and signing out, at its foot.
+// The stock shadcn sidebar: always a rail in icon mode on desktop, labels in tooltips; a sheet from
+// the left on phones, opened from the header and closed again on every pick. The organization
+// switcher sits at its top, with the organization's pages in groups under it; the person's own,
+// their account and signing out, at its foot.
export function AppSidebar() {
const {pathname} = useLocation();
const human = useHuman();
const experiments = useExperiments();
const {setOpenMobile} = useSidebar();
const home = `/${human?.org.slug ?? ''}`;
- const destinations = DESTINATIONS.filter(
- (destination) =>
- destination.experiment === undefined || experiments.includes(destination.experiment),
+ const groups = GROUPS.map((group) =>
+ group.filter(
+ (destination) =>
+ destination.experiment === undefined || experiments.includes(destination.experiment),
+ ),
);
return (
@@ -76,24 +76,26 @@ export function AppSidebar() {
-
-
- {destinations.map((destination) => (
-
- setOpenMobile(false)} />
- }
- >
-
- {destination.label}
-
-
- ))}
-
-
+ {groups.map((group, index) => (
+
+
+ {group.map((destination) => (
+
+ setOpenMobile(false)} />
+ }
+ >
+
+ {destination.label}
+
+
+ ))}
+
+
+ ))}
@@ -109,9 +111,6 @@ export function AppSidebar() {
Account
-
-
- pathname.startsWith(`/${activeOrg.slug}${page.to}`));
const open = async (slug: string) => {
setOpenMobile(false);
await navigate(`/${slug}`);
@@ -72,6 +82,7 @@ export function OrgSwitcher() {
// on the rail the same on every side, so it centers on the icons as they do.
}
@@ -103,6 +114,21 @@ export function OrgSwitcher() {
))}
+
+ {ORG_PAGES.map((page) => (
+ setOpenMobile(false)} />
+ }
+ >
+
+ {page.label}
+
+ ))}
+
+ setCreating(true)}>
Create organization…
diff --git a/packages/web/src/components/theme-menu.tsx b/packages/web/src/components/theme-menu.tsx
deleted file mode 100644
index 2b1c8c28..00000000
--- a/packages/web/src/components/theme-menu.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import {MonitorIcon, MoonIcon, SunIcon, type LucideIcon} from 'lucide-react';
-import {useTheme, type Theme} from '@proxy/ui/lib/theme';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuTrigger,
-} from '@proxy/ui/components/dropdown-menu';
-import {SidebarMenuButton} from '@proxy/ui/components/sidebar';
-
-type ThemeOption = {
- value: Theme;
- label: string;
- icon: LucideIcon;
-};
-
-const THEME_OPTIONS: Record = {
- light: {value: 'light', label: 'Light', icon: SunIcon},
- dark: {value: 'dark', label: 'Dark', icon: MoonIcon},
- system: {value: 'system', label: 'System', icon: MonitorIcon},
-};
-
-export function ThemeMenu() {
- const {theme, setTheme} = useTheme();
- const currentTheme = THEME_OPTIONS[theme];
- const CurrentThemeIcon = currentTheme.icon;
-
- return (
-
- }>
-
- Theme: {currentTheme.label}
-
-
-
- {Object.values(THEME_OPTIONS).map((option) => (
- setTheme(option.value)}
- >
-
- {option.label}
-
- ))}
-
-
-
- );
-}
diff --git a/packages/web/src/components/theme-picker.tsx b/packages/web/src/components/theme-picker.tsx
new file mode 100644
index 00000000..e0253a02
--- /dev/null
+++ b/packages/web/src/components/theme-picker.tsx
@@ -0,0 +1,38 @@
+import {MonitorIcon, MoonIcon, SunIcon, type LucideIcon} from 'lucide-react';
+import {useTheme, type Theme} from '@proxy/ui/lib/theme';
+import {Button} from '@proxy/ui/components/button';
+
+type ThemeOption = {
+ value: Theme;
+ label: string;
+ icon: LucideIcon;
+};
+
+const THEME_OPTIONS: ThemeOption[] = [
+ {value: 'light', label: 'Light', icon: SunIcon},
+ {value: 'dark', label: 'Dark', icon: MoonIcon},
+ {value: 'system', label: 'System', icon: MonitorIcon},
+];
+
+// The theme, on the person's account: one button per option, the one in use pressed, as the
+// toggle between what is theirs and what is shared looks.
+export function ThemePicker() {
+ const {theme, setTheme} = useTheme();
+
+ return (
+
+ {THEME_OPTIONS.map((option) => (
+
+ ))}
+
+ );
+}
diff --git a/packages/web/src/components/theme.test.tsx b/packages/web/src/components/theme.test.tsx
index 227ca380..82910747 100644
--- a/packages/web/src/components/theme.test.tsx
+++ b/packages/web/src/components/theme.test.tsx
@@ -2,9 +2,7 @@
import {afterEach, beforeEach, describe, expect, test} from 'bun:test';
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
-import {SidebarProvider} from '@proxy/ui/components/sidebar';
-import {TooltipProvider} from '@proxy/ui/components/tooltip';
-import {ThemeMenu} from '@/components/theme-menu';
+import {ThemePicker} from '@/components/theme-picker';
import {ThemeProvider} from '@proxy/ui/components/theme-provider';
import {
storeTheme,
@@ -166,20 +164,16 @@ describe('ThemeProvider', () => {
});
});
-test('the sidebar menu selects and persists each theme', async () => {
+test("the account's picker selects and persists each theme", async () => {
localStorage.setItem(THEME_STORAGE_KEY, 'dark');
- renderTheme(
-
-
-
-
- ,
- );
+ renderTheme();
- fireEvent.click(screen.getByRole('button', {name: 'Theme: Dark'}));
- expect(screen.getByRole('group', {name: 'Theme'})).toBeTruthy();
- fireEvent.click(await screen.findByRole('menuitemradio', {name: 'Light'}));
+ const group = screen.getByRole('group', {name: 'Theme'});
+ expect(group).toBeTruthy();
+ expect(screen.getByRole('button', {name: 'Dark'}).getAttribute('aria-pressed')).toBe('true');
+ fireEvent.click(screen.getByRole('button', {name: 'Light'}));
await waitFor(() => expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('light'));
expect(document.documentElement.classList.contains('light')).toBe(true);
+ expect(screen.getByRole('button', {name: 'Light'}).getAttribute('aria-pressed')).toBe('true');
});
diff --git a/packages/web/src/pages/account-page.tsx b/packages/web/src/pages/account-page.tsx
index d4456266..8d6b3ffb 100644
--- a/packages/web/src/pages/account-page.tsx
+++ b/packages/web/src/pages/account-page.tsx
@@ -8,13 +8,15 @@ import {OrgTile} from '@/components/org-switcher';
import {RowList} from '@/components/row-list';
import {Section} from '@/components/section';
import {SettingRow} from '@/components/setting-row';
+import {ThemePicker} from '@/components/theme-picker';
import {TypeToConfirmDialog} from '@/components/type-to-confirm-dialog';
import {useHuman} from '@/hooks/use-human';
import {describeApiError} from '@/lib/loader-utils';
import {hasOtherPeople} from '@/lib/orgs';
-// The person's account, in no organization: who is signed in, the organizations they are in, each
-// to its own settings, and deleting the account, which deletes every one they own.
+// The person's account, in no organization: who is signed in, how the app looks to them, the
+// organizations they are in, each to its own settings, and deleting the account, which deletes
+// every one they own.
export function AccountPage() {
const human = useHuman();
const [deleting, setDeleting] = useState(false);
@@ -34,6 +36,13 @@ export function AccountPage() {
{human.user.name && {human.user.name}}
+
+
+
+
+
+
+
{human.orgs.map((org) => (
diff --git a/packages/web/src/pages/connections-page.test.tsx b/packages/web/src/pages/connections-page.test.tsx
new file mode 100644
index 00000000..54561adc
--- /dev/null
+++ b/packages/web/src/pages/connections-page.test.tsx
@@ -0,0 +1,92 @@
+///
+
+import {afterEach, describe, expect, test} from 'bun:test';
+import {cleanup, render, screen} from '@testing-library/react';
+import {createMemoryRouter, RouterProvider} from 'react-router';
+import {ThemeProvider} from '@proxy/ui/components/theme-provider';
+import {TooltipProvider} from '@proxy/ui/components/tooltip';
+import type {Org} from '@/client/orgs-client';
+import {HumanSide} from '@/components/human-side';
+import type {Connection} from '@/lib/types';
+import {ConnectionsPage} from '@/pages/connections-page';
+import {testHuman, testOrg} from '@/test-org';
+
+afterEach(() => {
+ cleanup();
+});
+
+function connection(id: string, integrationId: string, shared: boolean): Connection {
+ return {
+ id,
+ integrationId,
+ account: 'Acme',
+ shared,
+ connectedById: 'user-1',
+ connectedBy: null,
+ defaults: [],
+ tools: null,
+ name: null,
+ unavailable: [],
+ signInNeeded: false,
+ };
+}
+
+// The organization's two vaults and a shared Google account; alone in it unless `org` says
+// otherwise.
+function renderConnectionsPage(entry: string, org: Org = testOrg) {
+ const router = createMemoryRouter(
+ [
+ {
+ id: 'human',
+ loader: () => testHuman(org),
+ element: ,
+ children: [
+ {
+ path: '/:org/connections',
+ loader: () => ({
+ connections: [
+ connection('vault-own', 'vault', false),
+ connection('vault-shared', 'vault', true),
+ connection('google-1', 'google', true),
+ ],
+ agents: [],
+ }),
+ element: ,
+ },
+ ],
+ },
+ ],
+ {initialEntries: [entry]},
+ );
+
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe('ConnectionsPage', () => {
+ test('alone in the organization, lists the vault once, first, opening both', async () => {
+ renderConnectionsPage('/acme/connections');
+
+ const [first] = await screen.findAllByRole('link', {name: /Vault|Google/});
+ expect(first?.textContent).toContain('Vault');
+ expect(first?.getAttribute('href')).toBe('/acme/connections/vault');
+ expect(screen.getAllByRole('link', {name: /Vault/})).toHaveLength(1);
+ });
+
+ test('with others in the organization, opens the vault of the side shown', async () => {
+ const shared = {...testOrg, memberCount: 2};
+ renderConnectionsPage('/acme/connections', shared);
+ const everyone = await screen.findByRole('link', {name: /Vault/});
+ expect(everyone.getAttribute('href')).toBe('/acme/connections/vault?for=everyone');
+ cleanup();
+
+ renderConnectionsPage('/acme/connections?for=me', shared);
+ const own = await screen.findByRole('link', {name: /Vault/});
+ expect(own.getAttribute('href')).toBe('/acme/connections/vault?for=me');
+ });
+});
diff --git a/packages/web/src/pages/connections-page.tsx b/packages/web/src/pages/connections-page.tsx
index 91c3dc2d..b0b31b8d 100644
--- a/packages/web/src/pages/connections-page.tsx
+++ b/packages/web/src/pages/connections-page.tsx
@@ -1,3 +1,4 @@
+import {VAULT_INTEGRATION_ID} from '@proxy/integrations';
import {PencilIcon, PlusIcon, Trash2Icon} from 'lucide-react';
import {useState} from 'react';
import {Link, useLoaderData, useRevalidator} from 'react-router';
@@ -16,7 +17,7 @@ import {useSide} from '@/hooks/use-side';
import {agentsWithAccess, integrationOf} from '@/lib/access';
import {describeApiError, describeFetchError} from '@/lib/loader-utils';
import {actsAs, manages} from '@/lib/orgs';
-import type {Connection} from '@/lib/types';
+import type {AgentLogin, Connection} from '@/lib/types';
import type {connectionsLoader} from '@/loaders';
import {useOrgSlug} from '@/hooks/use-org';
@@ -40,9 +41,10 @@ function titleOf(connection: Connection): string {
// The services connected in the organization open: its shared ones and the person's own, never
// another member's. With anyone else in it or invited, a toggle picks the list, in the address as
// `?for=me` or `?for=everyone`, opening on the shared one unless only the person's own has any, the
-// shared one saying who the provider sees when an agent uses each. The vault is kept on a page of
-// its own. A right click on a row renames or deletes a connection the person may change: their own,
-// or a shared one they connected or whose organization they own.
+// shared one saying who the provider sees when an agent uses each. The vault comes first, the
+// person's own or the organization's as the list shows, both alone, opening its own page. A right
+// click on a row renames or deletes a connection the person may change: their own, or a shared one
+// they connected or whose organization they own.
export function ConnectionsPage() {
const org = useOrgSlug();
const {connections: all, agents} = useLoaderData();
@@ -56,6 +58,12 @@ export function ConnectionsPage() {
return null;
}
+ const vaults = all.filter(
+ (connection) =>
+ connection.integrationId === VAULT_INTEGRATION_ID &&
+ (!side.others || connection.shared === side.shared),
+ );
+
const activeOrg = human.org;
const connections = side.shown;
const showingShared = side.shared;
@@ -96,6 +104,7 @@ export function ConnectionsPage() {
}
>
+
{connections.length === 0 && (
);
}
+
+// The vault the list shows, or both alone, as one row: what agents know, kept in Personal Agent
+// Proxy, so never renamed or deleted. Its page opens on the same side.
+function VaultRow({
+ vaults,
+ agents,
+ showingShared,
+}: {
+ vaults: Connection[];
+ agents: AgentLogin[];
+ showingShared: boolean;
+}) {
+ const org = useOrgSlug();
+ const human = useHuman();
+ const [first] = vaults;
+ const integration = first && integrationOf(first);
+ if (!first || !integration || !human) {
+ return null;
+ }
+
+ const withAccess = new Set(
+ vaults.flatMap((vault) => agentsWithAccess(agents, vault).map((agent) => agent.id)),
+ );
+ const side = vaults.length > 1 ? '' : `?for=${first.shared ? 'everyone' : 'me'}`;
+
+ return (
+ }
+ title={integration.name}
+ cells={
+ <>
+ {first.account}
+ {showingShared && {actsAs(first, human)}}
+ {withAccess.size}
+ >
+ }
+ />
+ );
+}
diff --git a/packages/web/src/pages/vault-page.test.tsx b/packages/web/src/pages/vault-page.test.tsx
index dbeeb1de..93c7179a 100644
--- a/packages/web/src/pages/vault-page.test.tsx
+++ b/packages/web/src/pages/vault-page.test.tsx
@@ -35,7 +35,7 @@ function renderVaultPage(entries: Entry[], options: {shared?: Entry[]; org?: Org
element: ,
children: [
{
- path: '/:org/vault',
+ path: '/:org/connections/vault',
loader: () => ({
own: vault('vault-1', entries),
shared: vault('vault-2', options.shared ?? []),
@@ -45,7 +45,7 @@ function renderVaultPage(entries: Entry[], options: {shared?: Entry[]; org?: Org
],
},
],
- {initialEntries: ['/acme/vault']},
+ {initialEntries: ['/acme/connections/vault']},
);
return render(
diff --git a/packages/web/src/pages/vault-page.tsx b/packages/web/src/pages/vault-page.tsx
index 6a38062b..42c1c7ea 100644
--- a/packages/web/src/pages/vault-page.tsx
+++ b/packages/web/src/pages/vault-page.tsx
@@ -13,6 +13,7 @@ import {useState} from 'react';
import {useLoaderData} from 'react-router';
import {createRecord, deleteRecord, updateRecord} from '@/client/records-client';
import {AppShell, PageTitle} from '@/components/app-shell';
+import {BackButton} from '@/components/back-button';
import {RecordForm} from '@/components/record-form';
import {Row, RowHeader, RowList} from '@/components/row-list';
import {SideEmpty, SideToggle} from '@/components/share-toggle';
@@ -70,7 +71,8 @@ type Editing = {listed: Listed | null};
// everyone's agents should know about the organization: a table of the entries, each opened for
// editing. With anyone else in the organization or invited, a toggle picks the vault, the person's
// own or the organization's, in the address as `?for=me` or `?for=everyone`, opening on their own;
-// alone, both vaults' entries are one list, and a new entry is the person's own.
+// alone, both vaults' entries are one list, and a new entry is the person's own. It is one of the
+// connections, opened from theirs.
export function VaultPage() {
const org = useOrgSlug();
const {own, shared} = useLoaderData();
@@ -101,7 +103,12 @@ export function VaultPage() {
return (
Vault}
+ title={
+ <>
+
+ Vault
+ >
+ }
actions={