Skip to content

Add 'All accounts' mode#12

Open
HansKristoffer wants to merge 3 commits into
ankitvgupta:mainfrom
HansKristoffer:main
Open

Add 'All accounts' mode#12
HansKristoffer wants to merge 3 commits into
ankitvgupta:mainfrom
HansKristoffer:main

Conversation

@HansKristoffer
Copy link
Copy Markdown

@HansKristoffer HansKristoffer commented Mar 30, 2026

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:

  • 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.


Open with Devin

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.
devin-ai-integration[bot]

This comment was marked as resolved.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Mar 30, 2026

Greptile Summary

This PR introduces an "All accounts" mode (currentAccountId === null) that lets users view and act on emails from all their inboxes in a single unified list. The approach is well-structured — effectiveAccountId is consistently propagated through EmailDetail, CommandPalette, AgentCommandPalette, useKeyboardShortcuts, and the store — and the account color palette / badge system for EmailRow is clean. However, several multi-account edge cases contain real bugs that will affect users with two or more accounts:

  • setSnoozedThreads / setArchiveReadyThreads last-write-wins race (EmailList.tsx): Both setters replace the entire store state. In All accounts mode a for loop fires N concurrent async calls; whichever account responds last silently overwrites all earlier results. Snoozed and archive-ready threads from other accounts disappear.
  • New email compose silently broken (EmailDetail.tsx): effectiveAccountId falls back to selectedEmail?.accountId, which is null when no email is open. The NewEmailCompose render guard && effectiveAccountId prevents the compose UI from appearing at all when users hit Compose from the inbox list in All accounts mode.
  • Batch undo uses a single accountId for cross-account selections (useBatchActions.ts): All four batch functions derive one effectiveAccountId from the first selected email. UndoActionItem.accountId is a single string, so the undo backend call will fail or act on the wrong account for any email not belonging to that first account.
  • nextPageToken for remote search only stores last account's token (SearchBar.tsx): "Load more" in multi-account search mode will only paginate results for the account whose remote search resolved last.

Confidence Score: 3/5

  • Not safe to merge yet — three P1 bugs will cause silent data loss or broken UI in the primary All accounts user path.
  • Four P1 issues found: snoozed/archive-ready threads silently lost in multi-account load (last-write-wins race), new email compose completely non-functional in All accounts mode with no open email, and batch-operation undo silently incorrect for cross-account selections. These are not edge cases — they hit the core feature being added. The store additions and effectiveAccountId propagation across individual email actions are solid, which prevents a lower score.
  • src/renderer/components/EmailList.tsx (snooze/archive-ready replacement bug), src/renderer/components/EmailDetail.tsx (compose broken with null effectiveAccountId), src/renderer/hooks/useBatchActions.ts (cross-account undo correctness)

Important Files Changed

Filename Overview
src/renderer/App.tsx Adds "All accounts" mode (currentAccountId=null), account-menu entry, aggregate sync/expired indicators, and a handler that eagerly loads emails for all accounts on switch. Logic looks correct though the green-dot indicator can show "Connected" even when some accounts have a sync error in All mode.
src/renderer/components/EmailList.tsx Extends snooze/archive-ready loading and event listeners to support All accounts mode. Contains two P1 race-condition bugs: setSnoozedThreads and setArchiveReadyThreads are called per-account in a loop but both replace (not merge) the full store state, so only the last response wins.
src/renderer/components/EmailDetail.tsx Replaces direct currentAccountId usage with effectiveAccountId (currentAccountId ?? selectedEmail?.accountId). Thread fetch, compose, reply-info, and action handlers all updated consistently. P1 issue: new-email compose silently does not render in All accounts mode when no email is selected because effectiveAccountId resolves to null.
src/renderer/hooks/useBatchActions.ts All four batch functions derive a single effectiveAccountId from the first selected email. In All accounts mode with cross-account selections, the undo action's accountId will be wrong for emails from other accounts, so undo will silently fail for those threads.
src/renderer/components/SearchBar.tsx Search iterates all accounts in All mode, merging local and remote results by deduplicating on id. P2 issue: nextPageToken is only stored for whichever account resolves last, breaking "load more" in multi-account search.
src/renderer/store/index.ts Adds deterministic ACCOUNT_COLORS palette, getAccountColor/getAccountLabel helpers, and updates setAccounts to default to null (All mode) for multi-account setups while preserving an already-set selection. Logic is clean.

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 ✅]
Loading

Comments Outside Diff (2)

  1. src/renderer/hooks/useBatchActions.ts, line 949-963 (link)

    P1 Single effectiveAccountId used for cross-account batch undo — undo will fail for non-matching accounts

    effectiveAccountId is taken from the first email found across all selected threads:

    const effectiveAccountId = currentAccountId ?? emails.find(e => selectedThreadIds.has(e.threadId))?.accountId ?? null;

    UndoActionItem holds a single accountId: string. When the undo is executed, the backend receives that one account ID for all emails in the batch. If the user batch-archived threads from account A and account B, the undo call will only succeed for account A's emails — account B's emails will either error silently or be operated on against the wrong account.

    The same issue exists in batchTrash, batchToggleStar, and batchMarkUnread.

    Fixing this properly requires either:

    1. Grouping selected threads by accountId before building the undo action (one UndoActionItem per account), or
    2. Adding an optional per-email accountId override to UndoActionItem so the executor can route each email to the correct account.
  2. src/renderer/components/SearchBar.tsx, line 920-925 (link)

    P2 nextPageToken is only stored for the last account to respond — "load more" broken in multi-account search

    Each account's remote search resolves independently and conditionally calls setRemoteSearchNextPageToken(response.data.nextPageToken). Because the responses arrive in non-deterministic order and the store holds only one nextPageToken, the token for pagination will be whichever account happened to resolve last. "Load more" will then only load additional results for that one account, silently skipping the others.

    When in "All accounts" mode, you may want to maintain a nextPageToken per account (e.g. Map<accountId, string>) and trigger load-more for all accounts that still have a pending token.

Reviews (1): Last reviewed commit: "Add 'All accounts' mode & multi-account ..." | Re-trigger Greptile

Comment on lines +121 to +135

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]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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));

Comment on lines +174 to 188
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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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) {
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.
devin-ai-integration[bot]

This comment was marked as resolved.

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.
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 new potential issues.

View 11 additional findings in Devin Review.

Open in Devin Review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/renderer/App.tsx
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/renderer/App.tsx
emailId: thread.latestEmail.id,
threadId: tid,
accountId: currentAccountId,
accountId: effectiveAccountId,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/renderer/App.tsx
Comment on lines +1376 to 1384
{!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" />
)}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Suggested change
{!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" />
)}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@ankitvgupta
Copy link
Copy Markdown
Owner

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants