the mailbox picker, so the multi-account backend is reachable
c60e3d6 made every route able to name its mailbox and left the UI unable to.
`?account=<id>` 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 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
+60
-4
@@ -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 = () => {
|
||||
<AccountsModal />
|
||||
<div className="flex shrink-0 items-center gap-2 border-b px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{/*
|
||||
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 ? (
|
||||
<div className="flex items-center gap-1 border-r border-border pr-1 mr-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 (
|
||||
<Link
|
||||
key={choice.id}
|
||||
to={{ search: `?${next}` }}
|
||||
className={`max-w-[7rem] truncate rounded px-2 py-1 text-xs transition-colors cursor-pointer ${
|
||||
isActive ? 'bg-accent font-medium' : 'opacity-60 hover:opacity-100 hover:bg-accent/50'
|
||||
}`}
|
||||
title={choice.title}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
>
|
||||
{choice.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{FOLDERS.map((f) => {
|
||||
const Icon = f.icon;
|
||||
const isActive = folder === f.key;
|
||||
|
||||
Reference in New Issue
Block a user