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
34 changes: 27 additions & 7 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import type {
} from "../shared/types";
import type { ScopedAgentEvent, AgentProviderConfig } from "../shared/agent-types";
import { mergeAndThreadSearchResults } from "./utils/searchResults";
import { buildSnoozeThreadAccounts } from "./utils/snooze-accounts";
import type { EmailThread } from "./store";

function decodeHtmlEntities(text: string): string {
Expand Down Expand Up @@ -2335,8 +2336,9 @@ function SnoozeOverlay() {
const { threads: currentThreads } = useThreadedEmails();

const selectedEmail = emails.find((e) => e.id === selectedEmailId);
const selectedAccountId = currentAccountId ?? selectedEmail?.accountId ?? null;

if (!showSnoozeMenu || !selectedEmail || !currentAccountId) return null;
if (!showSnoozeMenu || !selectedEmail || !selectedAccountId) return null;

// Determine if we're in batch mode (any multi-select, even 1 thread via 'x')
const isBatchSnooze = selectedThreadIds.size > 0;
Expand All @@ -2356,12 +2358,22 @@ function SnoozeOverlay() {
<SnoozeMenu
emailId={selectedEmail.id}
threadId={selectedEmail.threadId}
accountId={currentAccountId}
accountId={selectedAccountId}
onSnooze={(snoozedEmail: SnoozedEmail) => {
if (isBatchSnooze) {
// Batch snooze: snooze all selected threads using the same snoozeUntil time
const snoozeUntil = snoozedEmail.snoozeUntil;
const threadIdsToSnooze = Array.from(selectedThreadIds);
const threadAccountById = buildSnoozeThreadAccounts(
threadIdsToSnooze,
(tid) => {
const thread = currentThreads.find((t) => t.threadId === tid);
return thread ? { threadId: tid, accountId: thread.latestEmail.accountId } : undefined;
},
snoozedEmail.threadId,
snoozedEmail.accountId || selectedAccountId,
selectedAccountId,
);

// Close menu and clear selection immediately
useAppStore.setState({
Expand All @@ -2387,7 +2399,9 @@ function SnoozeOverlay() {
newSnoozedIds.add(snoozedEmail.threadId);
newSnoozedMap.set(snoozedEmail.threadId, snoozedEmail);

// Add remaining threads with unique ids and explicit accountId
// Add remaining threads with unique ids and explicit accountId.
// buildSnoozeThreadAccounts guarantees an entry for every thread,
// so the only skip is a thread that's no longer in the list.
for (const tid of otherThreadIds) {
const thread = currentThreads.find((t) => t.threadId === tid);
if (thread) {
Expand All @@ -2396,7 +2410,7 @@ function SnoozeOverlay() {
id: `snooze-${tid}-${Date.now()}`,
emailId: thread.latestEmail.id,
threadId: tid,
accountId: currentAccountId,
accountId: threadAccountById[tid],
snoozeUntil: snoozedEmail.snoozeUntil,
snoozedAt: snoozedEmail.snoozedAt,
});
Expand All @@ -2411,19 +2425,20 @@ function SnoozeOverlay() {
id: `snooze-batch-${Date.now()}`,
type: "snooze",
threadCount: threadIdsToSnooze.length,
accountId: currentAccountId,
accountId: selectedAccountId,
emails: [],
scheduledAt: Date.now(),
delayMs: 5000,
snoozedThreadIds: threadIdsToSnooze,
snoozedThreadAccounts: threadAccountById,
});

// Fire API calls for remaining threads in background
for (const tid of otherThreadIds) {
const thread = currentThreads.find((t) => t.threadId === tid);
if (thread) {
window.api.snooze
.snooze(thread.latestEmail.id, tid, currentAccountId, snoozeUntil)
.snooze(thread.latestEmail.id, tid, threadAccountById[tid], snoozeUntil)
.catch((err: unknown) =>
console.error("Batch snooze failed for thread", tid, err),
);
Expand Down Expand Up @@ -2464,16 +2479,21 @@ function SnoozeOverlay() {
return { snoozedThreadIds: newSnoozedIds, snoozedThreads: newSnoozedMap };
});

// Prefer the account of the email that was actually snoozed, matching
// the batch path; fall back to the selected account.
const snoozedAccountId = snoozedEmail.accountId || selectedAccountId;

// Queue undo synchronously to avoid race with other actions in the rAF delay
addUndoAction({
id: `snooze-${snoozedThreadId}-${Date.now()}`,
type: "snooze",
threadCount: 1,
accountId: currentAccountId,
accountId: snoozedAccountId,
emails: [],
scheduledAt: Date.now(),
delayMs: 5000,
snoozedThreadIds: [snoozedThreadId],
snoozedThreadAccounts: { [snoozedThreadId]: snoozedAccountId },
});
}
}}
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/components/UndoActionToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,9 @@ function UndoActionToastItem({ item }: { item: UndoActionItem }) {
const store = useAppStore.getState();
for (const threadId of item.snoozedThreadIds) {
store.removeSnoozedThread(threadId);
const threadAccountId = item.snoozedThreadAccounts?.[threadId] ?? item.accountId;
api()
.snooze.unsnooze(threadId, item.accountId)
.snooze.unsnooze(threadId, threadAccountId)
.catch((err: unknown) => {
console.error("Failed to unsnooze:", err);
});
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ export type UndoActionItem = {
archiveReadyThreadIds?: string[];
// For snooze undo: thread IDs to unsnooze
snoozedThreadIds?: string[];
// For cross-account snooze batches: thread ID -> owning account ID.
snoozedThreadAccounts?: Record<string, string>;
// For block: the bare sender email that was blocked. The block IPC is
// deferred (commitAction in UndoActionToast calls emails:block-sender when
// the timer elapses), and undo within the window simply restores the
Expand Down Expand Up @@ -1400,6 +1402,10 @@ export const useAppStore = create<AppState>((set, get) => ({
existing.snoozedThreadIds || item.snoozedThreadIds
? [...(existing.snoozedThreadIds || []), ...(item.snoozedThreadIds || [])]
: undefined,
snoozedThreadAccounts:
existing.snoozedThreadAccounts || item.snoozedThreadAccounts
? { ...(existing.snoozedThreadAccounts || {}), ...(item.snoozedThreadAccounts || {}) }
: undefined,
};
return {
undoActionQueue: state.undoActionQueue.map((i) => (i.id === existing.id ? merged : i)),
Expand Down
41 changes: 41 additions & 0 deletions src/renderer/utils/snooze-accounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Resolve the owning account for each thread in a batch snooze.
//
// In the "All Inboxes" view there is no active account filter, so each selected
// thread can belong to a different account. We map every thread to its own
// account (from its latest email) so the snooze IPC and the undo/unsnooze flow
// target the correct account per thread, falling back to the triggering email's
// account when a thread's account can't be resolved.

export interface ThreadAccountLookup {
threadId: string;
accountId: string | undefined;
}

/**
* Build a `threadId -> accountId` map for a batch snooze.
*
* @param threadIds all thread IDs being snoozed
* @param lookup resolves a thread ID to its latest email's account
* @param triggerThreadId the thread whose snooze opened the menu
* @param triggerAccountId that thread's account (already known)
* @param fallbackAccountId account to use when a thread can't be resolved
*/
export function buildSnoozeThreadAccounts(
threadIds: string[],
lookup: (threadId: string) => ThreadAccountLookup | undefined,
triggerThreadId: string,
triggerAccountId: string,
fallbackAccountId: string,
): Record<string, string> {
const map: Record<string, string> = {
[triggerThreadId]: triggerAccountId || fallbackAccountId,
};

for (const threadId of threadIds) {
if (map[threadId]) continue;
const resolved = lookup(threadId)?.accountId ?? fallbackAccountId;
if (resolved) map[threadId] = resolved;
}

return map;
}
82 changes: 81 additions & 1 deletion tests/e2e/snooze.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, Page, ElectronApplication } from "@playwright/test";
import { launchElectronApp , closeApp } from "./launch-helpers";
import { launchElectronApp, closeApp } from "./launch-helpers";

/**
* E2E Tests for Snooze Feature
Expand Down Expand Up @@ -155,6 +155,86 @@ test.describe("Snooze Feature — Menu & Presets", () => {
});
});

test.describe("Snooze Feature — All Inboxes", () => {
let electronApp: ElectronApplication;
let page: Page;

test.beforeAll(async ({}, testInfo) => {
const result = await launchElectronApp({ workerIndex: testInfo.workerIndex });
electronApp = result.app;
page = result.page;

await page.locator("[data-thread-id]").first().waitFor({ timeout: 15000 });
});

test.afterAll(async () => {
if (electronApp) {
await closeApp(electronApp);
}
});

test("opens snooze menu for a selected email in All Inboxes", async () => {
const selected = await page.evaluate(() => {
const store = (
window as unknown as {
__ZUSTAND_STORE__: {
getState: () => {
emails: Array<{ id: string; threadId: string; accountId?: string }>;
};
setState: (state: Record<string, unknown>) => void;
};
}
).__ZUSTAND_STORE__;
const state = store.getState();
const email = state.emails.find((item) => item.accountId) ?? state.emails[0];

store.setState({
currentAccountId: null,
selectedEmailId: email.id,
selectedThreadId: email.threadId,
showSnoozeMenu: false,
viewMode: "split",
});

return { accountId: email.accountId, threadId: email.threadId };
});

expect(selected.accountId).toBeTruthy();

await page.keyboard.press("h");

await expect(page.locator("text=Later Today")).toBeVisible({ timeout: 3000 });

await page.locator("button:has-text('Tomorrow')").first().click();
await expect(page.locator("text=Later Today")).not.toBeVisible({ timeout: 5000 });

const undoAccountId = await page.evaluate((threadId) => {
const store = (
window as unknown as {
__ZUSTAND_STORE__: {
getState: () => {
undoActionQueue: Array<{
type: string;
snoozedThreadAccounts?: Record<string, string>;
}>;
};
};
}
).__ZUSTAND_STORE__;

const snoozeUndo = store.getState().undoActionQueue.find((item) => item.type === "snooze");
return snoozeUndo?.snoozedThreadAccounts?.[threadId] ?? null;
}, selected.threadId);

expect(undoAccountId).toBe(selected.accountId);
});

// NOTE: A faithful cross-account *batch* E2E isn't possible in demo mode — the
// snooze IPC only accepts the single real demo account, so a second account
// can't round-trip. The cross-account thread→account mapping is covered by a
// unit test instead (tests/unit/snooze-accounts.spec.ts).
});

test.describe("Snooze Feature — Natural Language Input", () => {
test.describe.configure({ mode: "serial" });
let electronApp: ElectronApplication;
Expand Down
75 changes: 75 additions & 0 deletions tests/unit/snooze-accounts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { test, expect } from "@playwright/test";
import {
buildSnoozeThreadAccounts,
type ThreadAccountLookup,
} from "../../src/renderer/utils/snooze-accounts";

function lookupFrom(map: Record<string, string | undefined>) {
return (threadId: string): ThreadAccountLookup | undefined =>
threadId in map ? { threadId, accountId: map[threadId] } : undefined;
}

test.describe("buildSnoozeThreadAccounts", () => {
test("maps each thread to its own account across accounts", () => {
const result = buildSnoozeThreadAccounts(
["t1", "t2", "t3"],
lookupFrom({ t1: "account-a", t2: "account-b", t3: "account-c" }),
"t1",
"account-a",
"fallback",
);

expect(result).toEqual({ t1: "account-a", t2: "account-b", t3: "account-c" });
});

test("uses the trigger account for the triggering thread even if lookup differs", () => {
// The triggering thread's account is already known (from the snoozed email),
// so it must win over a (possibly stale) lookup value.
const result = buildSnoozeThreadAccounts(
["t1", "t2"],
lookupFrom({ t1: "stale", t2: "account-b" }),
"t1",
"account-a",
"fallback",
);

expect(result.t1).toBe("account-a");
expect(result.t2).toBe("account-b");
});

test("falls back when a thread's account can't be resolved", () => {
const result = buildSnoozeThreadAccounts(
["t1", "t2"],
lookupFrom({ t1: undefined, t2: undefined }), // present but no accountId
"t0",
"account-a",
"fallback",
);

expect(result).toEqual({ t0: "account-a", t1: "fallback", t2: "fallback" });
});

test("falls back when a thread is missing from the lookup entirely", () => {
const result = buildSnoozeThreadAccounts(
["t1"],
lookupFrom({}), // no threads known
"t0",
"account-a",
"fallback",
);

expect(result).toEqual({ t0: "account-a", t1: "fallback" });
});

test("uses the fallback for the trigger thread when its account is empty", () => {
const result = buildSnoozeThreadAccounts(
[],
lookupFrom({}),
"t0",
"",
"fallback",
);

expect(result).toEqual({ t0: "fallback" });
});
});