Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 100 additions & 25 deletions src/renderer/App.tsx
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

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.

Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAppStore, useThreadedEmails, type Account, type SyncStatus, type PrefetchProgress, type BackgroundSyncProgress } from "./store";
import { useAppStore, useThreadedEmails, getAccountColor, type Account, type SyncStatus, type PrefetchProgress, type BackgroundSyncProgress } from "./store";
import { EmailList } from "./components/EmailList";
import { EmailDetail } from "./components/EmailDetail";
import { EmailPreviewSidebar } from "./components/EmailPreviewSidebar";
Expand Down Expand Up @@ -170,7 +170,13 @@ function SearchResultsView() {
remoteSearchLoadingMore,
} = useAppStore();

const currentUserEmail = accounts.find(a => a.id === currentAccountId)?.email;
const searchUserEmails = useMemo(() => {
if (currentAccountId) {
const email = accounts.find(a => a.id === currentAccountId)?.email;
return email ? new Set([email]) : new Set<string>();
}
return new Set(accounts.map(a => a.email));
}, [currentAccountId, accounts]);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -259,11 +265,13 @@ function SearchResultsView() {

// Merge local and remote results, deduplicate, and group into threads
const searchThreads = useMemo(
() => mergeAndThreadSearchResults(activeSearchResults, remoteSearchResults, currentUserEmail),
[activeSearchResults, remoteSearchResults, currentUserEmail],
() => mergeAndThreadSearchResults(activeSearchResults, remoteSearchResults, searchUserEmails),
[activeSearchResults, remoteSearchResults, searchUserEmails],
);

const hasMoreResults = !!remoteSearchNextPageToken && remoteSearchStatus === "complete";
// "Load more" only works when a single account is selected — in All accounts
// mode the stored nextPageToken is ambiguous (could be from any account).
const hasMoreResults = !!currentAccountId && !!remoteSearchNextPageToken && remoteSearchStatus === "complete";

return (
<div className="flex-1 min-w-0 flex flex-col bg-white dark:bg-gray-800">
Expand Down Expand Up @@ -603,11 +611,23 @@ export default function App() {
}));
setAccounts(fullAccounts);

// Set current account to primary or first available
// Respect defaultAccountView config for startup account selection
const configResult = await window.api.settings.get();
const defaultView = configResult.success ? configResult.data.defaultAccountView ?? "all" : "all";

if (fullAccounts.length > 1) {
if (defaultView === "primary") {
const primary = fullAccounts.find(a => a.isPrimary) || fullAccounts[0];
setCurrentAccountId(primary.id);
} else {
setCurrentAccountId(null);
}
} else if (fullAccounts.length === 1) {
setCurrentAccountId(fullAccounts[0].id);
}

const primaryAccount = fullAccounts.find(a => a.isPrimary) || fullAccounts[0];
if (primaryAccount) {
setCurrentAccountId(primaryAccount.id);
// Identify user in PostHog using primary email
identifyUser(primaryAccount.email, {
account_count: fullAccounts.length,
});
Expand Down Expand Up @@ -1161,8 +1181,16 @@ export default function App() {
// Reload sent emails too
await reloadSentEmailsForAccount(currentAccountId);
} else {
// Fallback to legacy fetch for default account
await fetchEmails();
// All accounts mode: sync every account and reload emails
await Promise.all(accounts.map(async (account) => {
await window.api.sync.now(account.id);
const result = await window.api.sync.getEmails(account.id);
if (result.success && result.data) {
addEmails(result.data);
prefetchEmailBodies(result.data.map((e: DashboardEmail) => e.id)).catch(console.error);
}
await reloadSentEmailsForAccount(account.id);
}));
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch emails");
Expand Down Expand Up @@ -1230,9 +1258,12 @@ export default function App() {

// Get current account and its sync status
const currentAccount = accounts.find((a) => a.id === currentAccountId);
const isAllAccountsMode = currentAccountId === null && accounts.length > 0;
const currentSyncStatus = currentAccountId ? getSyncStatus(currentAccountId) : "idle";
const isSyncing = currentSyncStatus === "syncing";
const isAnySyncing = isAllAccountsMode && accounts.some((a) => getSyncStatus(a.id) === "syncing");
const isSyncing = currentSyncStatus === "syncing" || isAnySyncing;
const isCurrentAccountExpired = currentAccountId != null && expiredAccountIds.has(currentAccountId);
const isAnyAccountExpired = isAllAccountsMode && accounts.some((a) => expiredAccountIds.has(a.id));

// Build list of expired accounts with their email addresses for the banner
const expiredAccounts = accounts.filter((a) => expiredAccountIds.has(a.id));
Expand Down Expand Up @@ -1269,6 +1300,32 @@ export default function App() {
window.api.sync.now(accountId).catch(console.error);
};

const handleAllAccountsClick = () => {
setCurrentAccountId(null);
setAccountMenuOpen(false);
trackEvent("account_switched", { account_count: accounts.length, mode: "all" });

// Ensure all accounts have their emails loaded
const storeState = useAppStore.getState();
for (const account of accounts) {
if (!storeState.emails.some((e) => e.accountId === account.id)) {
window.api.sync.getEmails(account.id).then((result: IpcResponse<DashboardEmail[]>) => {
if (result.success && result.data && result.data.length > 0) {
addEmails(result.data);
prefetchEmailBodies(result.data.map((e: DashboardEmail) => e.id)).catch(console.error);
}
}).catch(console.error);
}
if (!storeState.sentEmails.some((e) => e.accountId === account.id)) {
window.api.sync.getSentEmails(account.id).then((sentResult: IpcResponse<DashboardEmail[]>) => {
if (sentResult.success && sentResult.data) {
addSentEmails(sentResult.data);
}
}).catch(console.error);
}
}
};

const handleCancelScheduled = async (id: string) => {
const result = await window.api.scheduledSend.cancel(id) as {
success: boolean;
Expand Down Expand Up @@ -1307,7 +1364,7 @@ export default function App() {
className="flex items-center space-x-2 px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
<span className="text-gray-700 dark:text-gray-300 truncate max-w-[200px]">
{currentAccount?.email || "Select account"}
{isAllAccountsMode ? "All accounts" : currentAccount?.email || "Select account"}
</span>
{/* Sync status indicator */}
{isSyncing && (
Expand All @@ -1316,13 +1373,13 @@ export default function App() {
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)}
{!isSyncing && !isCurrentAccountExpired && currentSyncStatus === "idle" && (
{!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" />
)}
Comment on lines +1376 to 1384

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.

<svg className="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
Expand All @@ -1334,6 +1391,21 @@ export default function App() {
{accountMenuOpen && (
<div className="absolute top-full left-0 mt-1 w-64 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg dark:shadow-black/40 z-50">
<div className="py-1">
{accounts.length > 1 && (
<button
onClick={handleAllAccountsClick}
className={`w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center space-x-2 ${
isAllAccountsMode ? "bg-blue-50 dark:bg-blue-900/30" : ""
}`}
>
<span className="flex -space-x-1">
{accounts.slice(0, 3).map((a) => (
<span key={a.id} className={`w-2 h-2 rounded-full ring-1 ring-white dark:ring-gray-800 ${getAccountColor(accounts, a.id).dot}`} />
))}
</span>
<span>All accounts</span>
</button>
)}
{accounts.map((account) => (
<button
key={account.id}
Expand All @@ -1343,11 +1415,11 @@ export default function App() {
}`}
>
<div className="flex items-center space-x-2">
<span className={`w-2 h-2 rounded-full ${
expiredAccountIds.has(account.id) ? "bg-amber-500" :
account.isConnected ? "bg-green-500" : "bg-gray-400 dark:bg-gray-500"
}`} />
<span className={`w-2 h-2 rounded-full ${getAccountColor(accounts, account.id).dot}`} />
<span className="truncate">{account.email}</span>
{expiredAccountIds.has(account.id) && (
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 flex-shrink-0" title="Session expired" />
)}
</div>
{account.isPrimary && (
<span className="text-xs text-gray-500 dark:text-gray-400">Primary</span>
Expand Down Expand Up @@ -1715,7 +1787,10 @@ function SnoozeOverlay() {

const selectedEmail = emails.find((e) => e.id === selectedEmailId);

if (!showSnoozeMenu || !selectedEmail || !currentAccountId) return null;
// In "All accounts" mode currentAccountId is null — derive from the selected email
const effectiveAccountId = currentAccountId ?? selectedEmail?.accountId ?? null;

if (!showSnoozeMenu || !selectedEmail || !effectiveAccountId) return null;

// Determine if we're in batch mode (any multi-select, even 1 thread via 'x')
const isBatchSnooze = selectedThreadIds.size > 0;
Expand All @@ -1735,7 +1810,7 @@ function SnoozeOverlay() {
<SnoozeMenu
emailId={selectedEmail.id}
threadId={selectedEmail.threadId}
accountId={currentAccountId}
accountId={effectiveAccountId}
onSnooze={(snoozedEmail: SnoozedEmail) => {
if (isBatchSnooze) {
// Batch snooze: snooze all selected threads using the same snoozeUntil time
Expand Down Expand Up @@ -1773,7 +1848,7 @@ function SnoozeOverlay() {
id: `snooze-${tid}-${Date.now()}`,
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.

snoozeUntil: snoozedEmail.snoozeUntil,
snoozedAt: snoozedEmail.snoozedAt,
});
Expand All @@ -1788,7 +1863,7 @@ function SnoozeOverlay() {
id: `snooze-batch-${Date.now()}`,
type: "snooze",
threadCount: threadIdsToSnooze.length,
accountId: currentAccountId,
accountId: effectiveAccountId,
emails: [],
scheduledAt: Date.now(),
delayMs: 5000,
Expand All @@ -1802,7 +1877,7 @@ function SnoozeOverlay() {
(window as any).api.snooze.snooze(
thread.latestEmail.id,
tid,
currentAccountId,
effectiveAccountId,
snoozeUntil,
).catch((err: unknown) => console.error("Batch snooze failed for thread", tid, err));
}
Expand Down Expand Up @@ -1845,7 +1920,7 @@ function SnoozeOverlay() {
id: `snooze-${snoozedThreadId}-${Date.now()}`,
type: "snooze",
threadCount: 1,
accountId: currentAccountId,
accountId: effectiveAccountId,
emails: [],
scheduledAt: Date.now(),
delayMs: 5000,
Expand Down
9 changes: 5 additions & 4 deletions src/renderer/components/AgentCommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,10 @@ export function AgentCommandPalette({ isOpen, onClose }: AgentCommandPaletteProp
);
const hasDraft = Boolean(selectedDraft);

const effectiveAccountId = currentAccountId ?? selectedEmail?.accountId ?? null;
const currentAccount = useMemo(
() => accounts.find((a) => a.id === currentAccountId),
[accounts, currentAccountId]
() => accounts.find((a) => a.id === effectiveAccountId),
[accounts, effectiveAccountId]
);

// Suggested actions based on email context
Expand Down Expand Up @@ -222,7 +223,7 @@ export function AgentCommandPalette({ isOpen, onClose }: AgentCommandPaletteProp

// Build context — include email metadata only when an email is selected
const context: AgentContext = {
accountId: currentAccountId ?? "",
accountId: effectiveAccountId ?? "",
userEmail: currentAccount?.email ?? "",
userName: currentAccount?.displayName,
};
Expand Down Expand Up @@ -274,7 +275,7 @@ export function AgentCommandPalette({ isOpen, onClose }: AgentCommandPaletteProp
},
[
selectedAgentIds,
currentAccountId,
effectiveAccountId,
selectedEmailId,
selectedDraftId,
selectedThreadId,
Expand Down
Loading