Add 'All accounts' mode#12
Conversation
Introduce an "All accounts" mode (currentAccountId === null) and propagate an effectiveAccountId throughout the renderer so actions, sync, compose, search, snooze, archive-ready, batch actions and keyboard shortcuts work when multiple accounts are selected. Key changes: - App: default selection logic updated to use "All accounts" when multiple accounts exist; added account switch handler to load/prefetch emails for all accounts; compute aggregate sync/expired status and update UI indicators. - Store: add deterministic account color palette and helpers (getAccountColor, getAccountLabel) for badges. - EmailList / EmailRow: show per-account badges in All mode, merge snooze/archiveReady loads across accounts, and listen for cross-account snooze/archive events. - EmailDetail, AgentCommandPalette, CommandPalette, SearchBar, useBatchActions, useKeyboardShortcuts: derive effectiveAccountId (currentAccountId || related email.accountId) so commands, optimistic actions, compose/reply info, searches (local+remote) and batch/keyboard operations target the correct account when in All mode. - Search: run local and remote searches across all accounts and merge results deduplicating by id. These changes make the app behave correctly when viewing or operating across all accounts while preserving single-account behavior.
Greptile SummaryThis PR introduces an "All accounts" mode (
Confidence Score: 3/5
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[User selects account] --> B{currentAccountId}
B -- specific ID --> C[Single account mode]
B -- null --> D[All accounts mode]
D --> E[EmailList: load snoozed/archive-ready for ALL accounts]
E --> F{setSnoozedThreads / setArchiveReadyThreads}
F -- ⚠️ Replaces state each call --> G[Race: only last account's data survives]
D --> H[User triggers action on email]
H --> I{effectiveAccountId = currentAccountId ?? email.accountId}
I -- email selected --> J[Correct account derived from email]
I -- no email selected + compose new --> K[⚠️ effectiveAccountId = null → compose silently skipped]
D --> L[User batch-selects threads from multiple accounts]
L --> M[effectiveAccountId = first email's accountId]
M --> N[UndoActionItem.accountId = single string]
N --> O[⚠️ Undo only works for first account's emails]
D --> P[Search across all accounts]
P --> Q[Local + remote searches per account merged by ID]
Q --> R[nextPageToken stored per call]
R --> S[⚠️ Only last account's token persists]
C --> T[All actions use currentAccountId directly ✅]
|
|
|
||
| for (const accountId of accountIds) { | ||
| (window as any).api.snooze.list(accountId).then((response: any) => { | ||
| if (response.success && response.data) { | ||
| setSnoozedThreads(response.data); | ||
| } | ||
| } | ||
| }); | ||
| }, [currentAccountId, setSnoozedThreads]); | ||
| if (response.expired?.length > 0) { | ||
| const store = useAppStore.getState(); | ||
| for (const email of response.expired) { | ||
| store.handleThreadUnsnoozed(email.threadId, email.snoozeUntil); | ||
| } | ||
| } | ||
| }); | ||
| } | ||
| }, [currentAccountId, accounts, setSnoozedThreads]); |
There was a problem hiding this comment.
setSnoozedThreads replaces state on each loop iteration, losing prior accounts' data
setSnoozedThreads in the store rebuilds the entire snoozedThreadIds Set and snoozedThreads Map from scratch based on the supplied array. In "All accounts" mode the loop fires N concurrent async calls, so whichever account's response arrives last will overwrite all previously loaded snoozed threads. Snoozed emails from other accounts will silently disappear.
The store's implementation confirms the overwrite behaviour:
// store/index.ts
setSnoozedThreads: (snoozedEmails) =>
set(() => {
const newIds = new Set<string>();
const newMap = new Map<string, SnoozedEmail>();
for (const se of snoozedEmails) { ... }
return { snoozedThreadIds: newIds, snoozedThreads: newMap };
}),The fix is to call addSnoozedThread for each entry instead of replacing the whole map, or to accumulate all results before a single setSnoozedThreads call:
const accumulated: SnoozedEmail[] = [];
const promises = accountIds.map((accountId) =>
(window as any).api.snooze.list(accountId).then((response: any) => {
if (response.success && response.data) {
accumulated.push(...response.data);
}
if (response.expired?.length > 0) {
const store = useAppStore.getState();
for (const email of response.expired) {
store.handleThreadUnsnoozed(email.threadId, email.snoozeUntil);
}
}
})
);
Promise.all(promises).then(() => setSnoozedThreads(accumulated));| if (accountIds.length === 0) return; | ||
|
|
||
| for (const accountId of accountIds) { | ||
| (window as any).api.archiveReady.getThreads(accountId).then((result: any) => { | ||
| if (result.success && result.data) { | ||
| const items = result.data.map((t: { threadId: string; reason: string }) => ({ | ||
| threadId: t.threadId, | ||
| reason: t.reason, | ||
| })); | ||
| setArchiveReadyThreads(items); | ||
| } | ||
| }); | ||
| } | ||
| }, [currentAccountId, accounts, setArchiveReadyThreads]); | ||
|
|
There was a problem hiding this comment.
setArchiveReadyThreads replaces state on each loop iteration, losing prior accounts' data
Same race condition as the snooze loop above. setArchiveReadyThreads discards the existing archiveReadyThreadIds Set and archiveReadyReasons Map and replaces them with only the items passed in:
// store/index.ts
setArchiveReadyThreads: (items) =>
set(() => {
const newIds = new Set<string>();
const newReasons = new Map<string, string>();
for (const item of items) { ... }
return { archiveReadyThreadIds: newIds, archiveReadyReasons: newReasons };
}),In "All accounts" mode, multiple accounts fire concurrent requests; the last to resolve will wipe out all the archive-ready threads loaded for earlier accounts. In the snoozed-view or archive-ready-view tabs, only one account's threads will actually be shown.
Accumulate across all accounts before a single setArchiveReadyThreads call, or add per-item via useAppStore.setState to merge rather than replace.
| @@ -2594,10 +2592,10 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { | |||
| }; | |||
There was a problem hiding this comment.
New email compose silently broken in "All accounts" mode when no email is selected
effectiveAccountId is derived as currentAccountId ?? selectedEmail?.accountId ?? null. When the user is in "All accounts" mode (currentAccountId === null) and no email is currently open (selectedEmail is undefined), effectiveAccountId resolves to null.
The guard below then silently skips rendering NewEmailCompose:
if (composeState?.isOpen && composeState.mode === "new" && effectiveAccountId) {
return <NewEmailCompose accountId={effectiveAccountId} ... />;
}The compose state is open but no UI is rendered, leaving the user on whatever view was showing previously with no feedback. A keyboard shortcut like C or the compose button would appear to do nothing.
Consider falling back to the primary account (or the first account) when in "All accounts" mode and no email context is available:
const effectiveAccountId =
currentAccountId
?? selectedEmail?.accountId
?? accounts.find(a => a.isPrimary)?.id
?? accounts[0]?.id
?? null;Support a unified "All accounts" inbox and ensure actions work correctly across multiple accounts. Use a Set of user emails for sent-detection and pass it through threading/search utilities. Derive effective account IDs when needed (falling back to primary/first account for compose) and only store remote search nextPageToken for single-account searches. Batch actions (archive/trash/star/unread) now group emails by account and enqueue one undo action per account so backend calls receive the correct accountId. Load snoozed/archive data in parallel and merge results before setting state. Add a Settings option to choose defaultAccountView (all or primary) and persist it in config schema. Also update tailwind plugin import to use @tailwindcss/forms via ESM import.
Use Promise.all when firing remote searches so the UI transitions remoteSearchStatus to "complete" even if all per-account responses return empty or get deduplicated; if the status remains "searching" after all promises resolve, force remoteSearchResults to an empty array to stop the spinner. Also update markThreadAsRead to derive the accountId from the thread's emails when currentAccountId is null (All accounts mode), ensuring the function can mark threads read across accounts.
There was a problem hiding this comment.
🔴 handleArchiveAll silently fails in All accounts mode
The handleArchiveAll callback at src/renderer/components/EmailList.tsx:256 checks if (!currentAccountId || threads.length === 0) return;, which causes it to early-return when currentAccountId is null (All accounts mode). However, the "Archive All" button is rendered at line 470-479 whenever isArchiveReadyView && threads.length > 0, regardless of account mode. In All accounts mode, the archive-ready view can contain threads from multiple accounts, so the button appears but clicking it does nothing.
(Refers to line 256)
Prompt for agents
In src/renderer/components/EmailList.tsx, the handleArchiveAll callback at line 256 guards with `if (!currentAccountId || threads.length === 0) return;`, which prevents the function from executing in All accounts mode where currentAccountId is null. To fix this, either:
1. Remove the `!currentAccountId` guard and group emails by account (similar to how useBatchActions.ts groups by account for undo actions), creating one undo action per account.
2. Or hide the "Archive All" button in All accounts mode by adding `&& currentAccountId` to the condition at line 470.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
🟡 retryRemoteSearch is non-functional in All accounts mode
The retryRemoteSearch callback at src/renderer/App.tsx:201 checks if (!activeSearchQuery || !currentAccountId || !isOnline) return;, causing it to early-return when currentAccountId is null. The Retry button is visible whenever remoteSearchStatus === "error" (line 345), which can occur in All accounts mode if remote searches for all accounts fail. The user sees a clickable "Retry" button that silently does nothing.
(Refers to line 201)
Prompt for agents
In src/renderer/App.tsx, the retryRemoteSearch callback at line 201 guards with `!currentAccountId` which blocks retry in All accounts mode. Fix by iterating over all accounts when currentAccountId is null (similar to the pattern in SearchBar.tsx performFullSearch), or hide the Retry button in All accounts mode by adding a condition at line 345.
Was this helpful? React with 👍 or 👎 to provide feedback.
| emailId: thread.latestEmail.id, | ||
| threadId: tid, | ||
| accountId: currentAccountId, | ||
| accountId: effectiveAccountId, |
There was a problem hiding this comment.
🔴 Batch snooze uses wrong accountId for cross-account threads in All accounts mode
In SnoozeOverlay, effectiveAccountId is derived from the single selected email (currentAccountId ?? selectedEmail?.accountId ?? null at line 1791). During batch snooze (lines 1842-1884), this single effectiveAccountId is used for ALL selected threads — in the optimistic snoozed state (line 1851), the undo action (line 1866), and the backend API calls (line 1880). In All accounts mode, if the user multi-selects threads from different accounts, threads from account B would be snoozed using account A's ID, causing the backend snooze.snooze() API calls to fail or operate on the wrong account, and the undo action to carry the wrong accountId.
Prompt for agents
In src/renderer/App.tsx SnoozeOverlay component, the batch snooze path (lines 1842-1884) uses `effectiveAccountId` for all threads regardless of which account they belong to. To fix this:
1. When building the snoozed thread records (line 1847-1854), derive each thread's accountId from `thread.latestEmail.accountId` instead of using `effectiveAccountId`.
2. When firing API calls (lines 1874-1884), pass `thread.latestEmail.accountId` instead of `effectiveAccountId`.
3. For the undo action (lines 1862-1871), either group by account (creating one undo action per account, similar to useBatchActions.ts), or store per-thread accountIds in the action.
Was this helpful? React with 👍 or 👎 to provide feedback.
| {!isSyncing && !isCurrentAccountExpired && !isAnyAccountExpired && (isAllAccountsMode || currentSyncStatus === "idle") && ( | ||
| <span className="w-2 h-2 rounded-full bg-green-500" title="Connected" /> | ||
| )} | ||
| {isCurrentAccountExpired && ( | ||
| {(isCurrentAccountExpired || isAnyAccountExpired) && ( | ||
| <span className="w-2 h-2 rounded-full bg-amber-500" title="Session expired" /> | ||
| )} | ||
| {!isCurrentAccountExpired && currentSyncStatus === "error" && ( | ||
| {!isCurrentAccountExpired && !isAnyAccountExpired && !isAllAccountsMode && currentSyncStatus === "error" && ( | ||
| <span className="w-2 h-2 rounded-full bg-red-500" title="Sync error" /> | ||
| )} |
There was a problem hiding this comment.
🟡 Sync error dot never shown in All accounts mode; green dot shows instead
At src/renderer/App.tsx:1382, the sync error indicator condition includes !isAllAccountsMode, so it never renders in All accounts mode. Meanwhile, the green "Connected" dot at line 1376 uses (isAllAccountsMode || currentSyncStatus === "idle") — since isAllAccountsMode is true, the green dot always shows regardless of any account's sync error state. There is no isAnySyncError variable analogous to isAnySyncing (line 1263). If one or more accounts have persistent sync errors in All accounts mode, the user sees a misleading green "Connected" indicator.
| {!isSyncing && !isCurrentAccountExpired && !isAnyAccountExpired && (isAllAccountsMode || currentSyncStatus === "idle") && ( | |
| <span className="w-2 h-2 rounded-full bg-green-500" title="Connected" /> | |
| )} | |
| {isCurrentAccountExpired && ( | |
| {(isCurrentAccountExpired || isAnyAccountExpired) && ( | |
| <span className="w-2 h-2 rounded-full bg-amber-500" title="Session expired" /> | |
| )} | |
| {!isCurrentAccountExpired && currentSyncStatus === "error" && ( | |
| {!isCurrentAccountExpired && !isAnyAccountExpired && !isAllAccountsMode && currentSyncStatus === "error" && ( | |
| <span className="w-2 h-2 rounded-full bg-red-500" title="Sync error" /> | |
| )} | |
| {!isSyncing && !isCurrentAccountExpired && !isAnyAccountExpired && (isAllAccountsMode ? !accounts.some((a) => getSyncStatus(a.id) === "error") : currentSyncStatus === "idle") && ( | |
| <span className="w-2 h-2 rounded-full bg-green-500" title="Connected" /> | |
| )} | |
| {(isCurrentAccountExpired || isAnyAccountExpired) && ( | |
| <span className="w-2 h-2 rounded-full bg-amber-500" title="Session expired" /> | |
| )} | |
| {!isCurrentAccountExpired && !isAnyAccountExpired && (isAllAccountsMode ? accounts.some((a) => getSyncStatus(a.id) === "error") : currentSyncStatus === "error") && ( | |
| <span className="w-2 h-2 rounded-full bg-red-500" title="Sync error" /> | |
| )} |
Was this helpful? React with 👍 or 👎 to provide feedback.
|
Nice you beat me to this If you want to accelerate getting this out, please install gstack and use the /office-hours, /plan-eng-review and /qa skills to verify your approach thanks! |
I wanted a way to view all my inboxes in one view, so implemented a "All accounts" in the account selector. If you dont want PRs or this do not make sense, please feel fee to close it :)
Thanks for a great project!
Introduce an "All accounts" mode (currentAccountId === null) and propagate an effectiveAccountId throughout the renderer so actions, sync, compose, search, snooze, archive-ready, batch actions and keyboard shortcuts work when multiple accounts are selected. Key changes:
These changes make the app behave correctly when viewing or operating across all accounts while preserving single-account behavior.