From e07adfc296d7a49d66e7698042fff382f7aed01a Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:09:10 +0000 Subject: [PATCH 1/2] group the sidebar, move members and settings into the org switcher and the vault into connections --- packages/docs/content/connections/vault.mdx | 6 +- packages/docs/content/organizations.mdx | 11 ++- packages/web/src/components/nav.test.tsx | 93 ++++++++++++++----- packages/web/src/components/nav.tsx | 75 ++++++++------- packages/web/src/components/org-switcher.tsx | 34 ++++++- .../web/src/pages/connections-page.test.tsx | 92 ++++++++++++++++++ packages/web/src/pages/connections-page.tsx | 57 +++++++++++- packages/web/src/pages/vault-page.test.tsx | 4 +- packages/web/src/pages/vault-page.tsx | 11 ++- packages/web/src/router.tsx | 13 ++- 10 files changed, 314 insertions(+), 82 deletions(-) create mode 100644 packages/web/src/pages/connections-page.test.tsx 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/web/src/components/nav.test.tsx b/packages/web/src/components/nav.test.tsx index 3a883db3..42580300 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..6a6d56c6 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'; @@ -33,16 +30,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, @@ -53,17 +52,19 @@ function isActive(pathname: string, path: string, home: string): boolean { // 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. +// organization switcher sits at its top, with the organization's pages in groups under it; the +// person's own, their account, theme 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 +77,26 @@ export function AppSidebar() { - - - {destinations.map((destination) => ( - - setOpenMobile(false)} /> - } - > - - {destination.label} - - - ))} - - + {groups.map((group, index) => ( + + + {group.map((destination) => ( + + setOpenMobile(false)} /> + } + > + + {destination.label} + + + ))} + + + ))} diff --git a/packages/web/src/components/org-switcher.tsx b/packages/web/src/components/org-switcher.tsx index 98d6d513..6bd6dd1f 100644 --- a/packages/web/src/components/org-switcher.tsx +++ b/packages/web/src/components/org-switcher.tsx @@ -1,6 +1,6 @@ -import {CheckIcon, ChevronsUpDownIcon, PlusIcon} from 'lucide-react'; +import {CheckIcon, ChevronsUpDownIcon, PlusIcon, SettingsIcon, UsersIcon} from 'lucide-react'; import {useState} from 'react'; -import {useNavigate} from 'react-router'; +import {Link, useLocation, useNavigate} from 'react-router'; import {createOrg} from '@/client/orgs-client'; import {Button} from '@proxy/ui/components/button'; import { @@ -16,6 +16,7 @@ import { DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, + DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@proxy/ui/components/dropdown-menu'; @@ -42,19 +43,28 @@ export function OrgTile({org, className}: {org: {name: string}; className?: stri ); } +// The organization's own pages, in the switcher under its organizations. +const ORG_PAGES = [ + {to: '/members', label: 'Members', icon: UsersIcon}, + {to: '/settings', label: 'Settings', icon: SettingsIcon}, +]; + // The top of the sidebar's rail: the organization the address names, as its mark. Hovering it, or -// clicking it on a touch screen, opens the switcher beside the rail. Everything on the human side -// is that organization's; switching goes to another's address, its Home. +// clicking it on a touch screen, opens the switcher beside the rail, with the organization's +// members and settings, lit while one of them is open. Everything on the human side is that +// organization's; switching goes to another's address, its Home. export function OrgSwitcher() { const human = useHuman(); const {isMobile, setOpenMobile} = useSidebar(); const navigate = useNavigate(); + const {pathname} = useLocation(); const [creating, setCreating] = useState(false); if (!human) { return null; } const activeOrg = human.org; + const onOrgPage = ORG_PAGES.some((page) => 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/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={ ); } -function SidebarRail({className, ...props}: React.ComponentProps<'button'>) { - const {toggleSidebar} = useSidebar(); - - return ( - + ))} + + ); +} 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) => (