From c1c367456bf06d796804d5eaa57edf6245fb375c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 23:34:08 +0000 Subject: [PATCH] the mailbox picker, so the multi-account backend is reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit c60e3d6 made every route able to name its mailbox and left the UI unable to. `?account=` names one and `?account=all` spans them; nothing on screen passed either, so the list showed whichever account sorted first enabled. The selection is a QUERY PARAM, the same as the folder beside it and for the same reason — it is addressable state, so a reload, a shared URL and a cmd-click all land where they were meant to. The pills are real links built from the live params, so the folder and any open email survive a mailbox switch. `account` is in the react-query key. Without it, switching mailbox would serve the previous one's page from cache under the new selection, which reads as the filter not working rather than as a caching bug. Absent means `all` in the UI and only in the UI. The routes keep treating absent as "first enabled" — mail-scope.ts does that deliberately, because the mobile app and the agent's email_db tool call them and know nothing about accounts. That default is right for a caller that cannot choose and wrong for a person looking at a screen: three accounts and you want all of it, not whichever sorts first. Hidden with fewer than two accounts. A control that can say one thing is noise in a toolbar that is already tight on a phone. NOT visually verified: this machine has no email accounts configured, so the picker cannot render here. tsgo clean, prettier clean, the 16 mail-scope tests pass. It needs a look on a host with two accounts before it is trusted. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- web/EmailList.tsx | 64 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/web/EmailList.tsx b/web/EmailList.tsx index 2999737..a9109eb 100644 --- a/web/EmailList.tsx +++ b/web/EmailList.tsx @@ -63,6 +63,17 @@ export const EmailList = () => { // with a message open should not close it. const [searchParams, setSearchParams] = useSearchParams(); const folder = searchParams.get('folder') ?? 'inbox'; + /* + WHICH MAILBOX, on the same reasoning as the folder above: addressable state, so a query param rather + than component state. Reload, share the URL or cmd-click a mailbox and you land where you meant to. + + Absent means `all` HERE and only here. The routes themselves treat absent as "first enabled", which + `mail-scope.ts` keeps deliberately so the mobile app and the agent's email_db tool — neither of which + knows accounts exist — carry on working. That default is right for a caller that cannot choose and + wrong for a person looking at a screen: someone with three accounts opening their mail wants all of + it, not whichever one happens to sort first. + */ + const account = searchParams.get('account') ?? 'all'; const qs = searchParams.toString() ? `?${searchParams}` : ''; const [page, setPage] = useState(1); const [search, setSearch] = useState(''); @@ -76,7 +87,7 @@ export const EmailList = () => { // cannot any more, because the folder pills are links and nobody handles their click. useEffect(() => { setPage(1); - }, [debouncedSearch, folder]); + }, [debouncedSearch, folder, account]); const { data: emailAccounts = [], refetch: refetchAccounts } = useQuery({ queryKey: ['email-accounts'], @@ -87,14 +98,18 @@ export const EmailList = () => { const hasAccounts = emailAccounts.length > 0; const { data, isLoading } = useQuery({ - queryKey: isSearching ? ['email-search', debouncedSearch, page] : ['email-messages', page, folder], + // `account` is in the key: switching mailbox has to refetch, and without it react-query would serve + // the previous mailbox's page from cache under the new selection. + queryKey: isSearching + ? ['email-search', debouncedSearch, page, account] + : ['email-messages', page, folder, account], queryFn: () => isSearching ? client.get<{ messages: EmailSummary[]; total: number }>( - `/email/search?q=${encodeURIComponent(debouncedSearch)}&page=${page}&limit=${LIMIT}`, + `/email/search?q=${encodeURIComponent(debouncedSearch)}&page=${page}&limit=${LIMIT}&account=${account}`, ) : client.get<{ messages: EmailSummary[]; total: number }>( - `/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`, + `/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}&account=${account}`, ), // Keep the previous results visible while the next query loads, so switching search terms/pages // never bails to the full-panel loading view (which would unmount the search input and drop focus). @@ -271,6 +286,47 @@ export const EmailList = () => {
+ {/* + THE MAILBOX PICKER, and only when there is a choice to make. + + One account and this is a control that can say exactly one thing, taking space in a toolbar + that is already tight on a phone. It appears when a second account arrives and disappears if + one is removed. + + Real links, like the folder pills beside it: built from the LIVE params so the folder and any + open email survive the switch, and so cmd-click, middle-click and Back all behave. See + platform docs/navigation-audit.md — selection state is a URL, never a channel. + */} + {emailAccounts.length > 1 ? ( +
+ {[ + { id: 'all', label: 'All mail', title: 'Every account' }, + ...emailAccounts.map((a) => ({ + id: String(a.id), + // The address distinguishes two mailboxes; a display name often does not. + label: a.email.split('@')[0] ?? a.email, + title: a.email, + })), + ].map((choice) => { + const isActive = account === choice.id; + const next = new URLSearchParams(searchParams); + next.set('account', choice.id); + return ( + + {choice.label} + + ); + })} +
+ ) : null} {FOLDERS.map((f) => { const Icon = f.icon; const isActive = folder === f.key;