From b9797da2799305dc60ef6dfd04825ee77d71c39e Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:00:40 -0700 Subject: [PATCH 1/7] Fix split label conditions: match by name and check all thread emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split conditions with type "label" previously compared the user-entered label name (e.g., "-Respond") against email.labelIds, which contains Gmail API IDs (e.g., "Label_1496681257177018521"). These never matched. This adds a label name→ID lookup populated on app mount via a new labels:list IPC endpoint, so split conditions resolve label names to their Gmail IDs before matching. Additionally, label-based splits now check all emails in a thread rather than only the latest. Gmail labels are thread-level, but only the message that was present during the initial sync may carry the label in the local DB. Checking all thread emails ensures label splits match correctly. Changes: - Add listLabels() to GmailClient (gmail.users.labels.list) - Add labels:list IPC handler and preload exposure - Populate label name→ID map on app mount in App.tsx - Update split-conditions.ts to resolve label names to IDs - Update threadMatchesSplit in SplitTabs.tsx and store/index.ts to check all thread emails for label conditions --- src/main/index.ts | 2 ++ src/main/ipc/labels.ipc.ts | 27 ++++++++++++++++++++++++++ src/main/services/gmail-client.ts | 16 +++++++++++++++ src/preload/index.ts | 6 ++++++ src/renderer/App.tsx | 10 ++++++++++ src/renderer/components/SplitTabs.tsx | 6 ++++++ src/renderer/store/index.ts | 6 ++++++ src/renderer/utils/split-conditions.ts | 20 ++++++++++++++++++- 8 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 src/main/ipc/labels.ipc.ts diff --git a/src/main/index.ts b/src/main/index.ts index 6a9373a3..4a676dbd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -56,6 +56,7 @@ import { registerSearchIpc } from "./ipc/search.ipc"; import { registerOutboxIpc, registerNetworkIpc } from "./ipc/outbox.ipc"; import { registerMemoryIpc } from "./ipc/memory.ipc"; import { registerSplitsIpc } from "./ipc/splits.ipc"; +import { registerLabelsIpc } from "./ipc/labels.ipc"; import { registerArchiveReadyIpc } from "./ipc/archive-ready.ipc"; import { registerSnoozeIpc } from "./ipc/snooze.ipc"; import { registerScheduledSendIpc } from "./ipc/scheduled-send.ipc"; @@ -391,6 +392,7 @@ app.whenReady().then(async () => { registerOutboxIpc(); registerMemoryIpc(); registerSplitsIpc(); + registerLabelsIpc(); registerArchiveReadyIpc(); registerSnoozeIpc(); registerScheduledSendIpc(); diff --git a/src/main/ipc/labels.ipc.ts b/src/main/ipc/labels.ipc.ts new file mode 100644 index 00000000..f8e89afd --- /dev/null +++ b/src/main/ipc/labels.ipc.ts @@ -0,0 +1,27 @@ +import { ipcMain } from "electron"; +import { getClient } from "./gmail.ipc"; +import type { IpcResponse } from "../../shared/types"; + +interface LabelInfo { + id: string; + name: string; + type: string; + color?: { textColor: string; backgroundColor: string }; +} + +export function registerLabelsIpc(): void { + // List all labels for an account + ipcMain.handle( + "labels:list", + async (_, { accountId }: { accountId: string }): Promise> => { + try { + const client = await getClient(accountId); + const labels = await client.listLabels(); + return { success: true, data: labels }; + } catch (error) { + console.error("[Labels] Failed to list labels:", error); + return { success: false, error: String(error) }; + } + } + ); +} diff --git a/src/main/services/gmail-client.ts b/src/main/services/gmail-client.ts index a20d63f9..ab89c1fb 100644 --- a/src/main/services/gmail-client.ts +++ b/src/main/services/gmail-client.ts @@ -504,6 +504,22 @@ export class GmailClient { return allMessages; } + /** + * List all labels for the authenticated account. + * Returns id, name, type, and optional color for each label. + */ + async listLabels(): Promise> { + const gmail = this.gmail!; + const response = await gmail.users.labels.list({ userId: "me" }); + const rawLabels = response.data.labels || []; + return rawLabels.map((l) => ({ + id: l.id!, + name: l.name!, + type: l.type || "user", + ...(l.color ? { color: { textColor: l.color.textColor!, backgroundColor: l.color.backgroundColor! } } : {}), + })); + } + /** * Get the total number of messages with a given label. * Uses the labels.get endpoint which returns exact counts. diff --git a/src/preload/index.ts b/src/preload/index.ts index a5073163..10e0efe6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -144,6 +144,12 @@ const api = { ipcRenderer.invoke("emails:search-remote", { query, accountId, maxResults, pageToken }), }, + // Label operations + labels: { + list: (accountId: string): Promise => + ipcRenderer.invoke("labels:list", { accountId }), + }, + // Style operations style: { getContext: (toAddress: string): Promise => diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index fcc41ccc..d5eba86a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,6 +1,7 @@ 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 { setLabelMap } from "./utils/split-conditions"; import { EmailList } from "./components/EmailList"; import { EmailDetail } from "./components/EmailDetail"; import { EmailPreviewSidebar } from "./components/EmailPreviewSidebar"; @@ -580,6 +581,15 @@ export default function App() { }); }, [setSplits]); + // Populate label name→ID map so split conditions can match labels by name + useEffect(() => { + window.api.labels.list("default").then((result: { success: boolean; data?: Array<{ id: string; name: string }> }) => { + if (result.success && result.data) { + setLabelMap(result.data); + } + }).catch(() => {}); + }, []); + // Initialize sync and accounts const initializeSync = useCallback(async () => { try { diff --git a/src/renderer/components/SplitTabs.tsx b/src/renderer/components/SplitTabs.tsx index 6f54563a..094b786d 100644 --- a/src/renderer/components/SplitTabs.tsx +++ b/src/renderer/components/SplitTabs.tsx @@ -4,6 +4,12 @@ import type { InboxSplit } from "../../shared/types"; import { emailMatchesSplit } from "../utils/split-conditions"; function threadMatchesSplit(thread: EmailThread, split: InboxSplit): boolean { + // For label conditions, check ALL emails in the thread (Gmail labels are thread-level). + // For other condition types (from, to, subject), keep checking only the latest email. + const hasLabelCondition = split.conditions.some((c) => c.type === "label"); + if (hasLabelCondition) { + return thread.emails.some((email) => emailMatchesSplit(email, split)); + } return emailMatchesSplit(thread.latestEmail, split); } diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 988660e9..771c02af 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -1796,6 +1796,12 @@ export function useThreadedEmails() { } function threadMatchesSplit(thread: EmailThread, split: InboxSplit): boolean { + // For label conditions, check ALL emails in the thread (Gmail labels are thread-level). + // For other condition types (from, to, subject), keep checking only the latest email. + const hasLabelCondition = split.conditions.some((c) => c.type === "label"); + if (hasLabelCondition) { + return thread.emails.some((email) => emailMatchesSplit(email, split)); + } return emailMatchesSplit(thread.latestEmail, split); } diff --git a/src/renderer/utils/split-conditions.ts b/src/renderer/utils/split-conditions.ts index 42ef2932..898a06e5 100644 --- a/src/renderer/utils/split-conditions.ts +++ b/src/renderer/utils/split-conditions.ts @@ -1,5 +1,16 @@ import type { InboxSplit, DashboardEmail } from "../../shared/types"; +// Label name → ID lookup for split condition matching. +// Populated asynchronously on app mount; falls back to direct ID comparison when empty. +const labelNameToIds = new Map(); + +export function setLabelMap(labels: Array<{ id: string; name: string }>): void { + labelNameToIds.clear(); + for (const label of labels) { + labelNameToIds.set(label.name.toLowerCase(), label.id); + } +} + // Convert a glob-like pattern to a regex // Supports: * (matches anything), ? (matches single char) function patternToRegex(pattern: string): RegExp { @@ -45,7 +56,14 @@ export function evaluateCondition(email: DashboardEmail, condition: InboxSplit[" break; } case "label": { - matches = email.labelIds?.includes(condition.value) ?? false; + // Match by label ID directly, or resolve label name → ID + const labelIds = email.labelIds ?? []; + if (labelIds.includes(condition.value)) { + matches = true; + } else { + const resolvedId = labelNameToIds.get(condition.value.toLowerCase()); + matches = resolvedId ? labelIds.includes(resolvedId) : false; + } break; } case "has_attachment": { From 47c6863f90429f5bf0a4fde0159176d047844ad5 Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:27:31 -0700 Subject: [PATCH 2/7] Address review feedback: account ID, mixed conditions, multi-account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use currentAccountId from store instead of hardcoded "default" for label list fetch; re-fetch when active account changes - Separate label and non-label conditions in threadMatchesSplit: label conditions check all thread emails, non-label conditions check only the latest email, then combine with the split's AND/OR logic - Key the label name→ID map by accountId so multi-account setups with same-named labels don't clobber each other --- src/renderer/App.tsx | 10 +++++---- src/renderer/components/SplitTabs.tsx | 29 +++++++++++++++++++------- src/renderer/store/index.ts | 29 +++++++++++++++++++------- src/renderer/utils/split-conditions.ts | 22 +++++++++++++------ 4 files changed, 66 insertions(+), 24 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d5eba86a..68a73d53 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -581,14 +581,16 @@ export default function App() { }); }, [setSplits]); - // Populate label name→ID map so split conditions can match labels by name + // Populate label name→ID map so split conditions can match labels by name. + // Re-fetches when the active account changes (different accounts may have different labels). useEffect(() => { - window.api.labels.list("default").then((result: { success: boolean; data?: Array<{ id: string; name: string }> }) => { + if (!currentAccountId) return; + window.api.labels.list(currentAccountId).then((result: { success: boolean; data?: Array<{ id: string; name: string }> }) => { if (result.success && result.data) { - setLabelMap(result.data); + setLabelMap(currentAccountId, result.data); } }).catch(() => {}); - }, []); + }, [currentAccountId]); // Initialize sync and accounts const initializeSync = useCallback(async () => { diff --git a/src/renderer/components/SplitTabs.tsx b/src/renderer/components/SplitTabs.tsx index 094b786d..7845794a 100644 --- a/src/renderer/components/SplitTabs.tsx +++ b/src/renderer/components/SplitTabs.tsx @@ -1,16 +1,31 @@ import { useMemo } from "react"; import { useAppStore, useThreadedEmails, type EmailThread } from "../store"; import type { InboxSplit } from "../../shared/types"; -import { emailMatchesSplit } from "../utils/split-conditions"; +import { emailMatchesSplit, evaluateCondition } from "../utils/split-conditions"; function threadMatchesSplit(thread: EmailThread, split: InboxSplit): boolean { - // For label conditions, check ALL emails in the thread (Gmail labels are thread-level). - // For other condition types (from, to, subject), keep checking only the latest email. - const hasLabelCondition = split.conditions.some((c) => c.type === "label"); - if (hasLabelCondition) { - return thread.emails.some((email) => emailMatchesSplit(email, split)); + const labelConditions = split.conditions.filter((c) => c.type === "label"); + + // No label conditions — use the original latest-email-only path + if (labelConditions.length === 0) { + return emailMatchesSplit(thread.latestEmail, split); } - return emailMatchesSplit(thread.latestEmail, split); + + // Evaluate label conditions against ANY email in the thread (Gmail labels are thread-level) + const labelResults = labelConditions.map((c) => + thread.emails.some((email) => evaluateCondition(email, c)) + ); + + // Evaluate non-label conditions against only the latest email + const otherConditions = split.conditions.filter((c) => c.type !== "label"); + const otherResults = otherConditions.map((c) => + evaluateCondition(thread.latestEmail, c) + ); + + const allResults = [...labelResults, ...otherResults]; + return split.conditionLogic === "and" + ? allResults.every(Boolean) + : allResults.some(Boolean); } interface TabProps { diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 771c02af..dc874fd6 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -3,7 +3,7 @@ import { create } from "zustand"; import { clearPendingLabelUpdates } from "../hooks-bridge"; import { applyOptimisticReads, addOptimisticReads } from "../optimistic-reads"; import type { DashboardEmail, ComposeMode, OutboxStats, InboxSplit, ThemePreference, InboxDensity, SnoozedEmail, ScheduledMessageStats, SendMessageOptions, LocalDraft } from "../../shared/types"; -import { emailMatchesSplit } from "../utils/split-conditions"; +import { emailMatchesSplit, evaluateCondition } from "../utils/split-conditions"; import type { AgentProviderConfig, AgentTaskInfo, AgentProviderRun, AgentTaskHistoryEntry, ScopedAgentEvent, PendingConfirmation, AgentContext } from "../../shared/agent-types"; export type SettingsTab = "general" | "accounts" | "calendar" | "splits" | "signatures" | "prompts" | "style" | "assistant" | "memories" | "queue" | "agents" | "analytics" | "extensions"; @@ -1796,13 +1796,28 @@ export function useThreadedEmails() { } function threadMatchesSplit(thread: EmailThread, split: InboxSplit): boolean { - // For label conditions, check ALL emails in the thread (Gmail labels are thread-level). - // For other condition types (from, to, subject), keep checking only the latest email. - const hasLabelCondition = split.conditions.some((c) => c.type === "label"); - if (hasLabelCondition) { - return thread.emails.some((email) => emailMatchesSplit(email, split)); + const labelConditions = split.conditions.filter((c) => c.type === "label"); + + // No label conditions — use the original latest-email-only path + if (labelConditions.length === 0) { + return emailMatchesSplit(thread.latestEmail, split); } - return emailMatchesSplit(thread.latestEmail, split); + + // Evaluate label conditions against ANY email in the thread (Gmail labels are thread-level) + const labelResults = labelConditions.map((c) => + thread.emails.some((email) => evaluateCondition(email, c)) + ); + + // Evaluate non-label conditions against only the latest email + const otherConditions = split.conditions.filter((c) => c.type !== "label"); + const otherResults = otherConditions.map((c) => + evaluateCondition(thread.latestEmail, c) + ); + + const allResults = [...labelResults, ...otherResults]; + return split.conditionLogic === "and" + ? allResults.every(Boolean) + : allResults.some(Boolean); } // Selector for split-filtered threaded emails diff --git a/src/renderer/utils/split-conditions.ts b/src/renderer/utils/split-conditions.ts index 898a06e5..89282986 100644 --- a/src/renderer/utils/split-conditions.ts +++ b/src/renderer/utils/split-conditions.ts @@ -1,14 +1,24 @@ import type { InboxSplit, DashboardEmail } from "../../shared/types"; -// Label name → ID lookup for split condition matching. +// Label name → ID lookup for split condition matching, keyed by account. // Populated asynchronously on app mount; falls back to direct ID comparison when empty. -const labelNameToIds = new Map(); +const labelNameToIdsByAccount = new Map>(); -export function setLabelMap(labels: Array<{ id: string; name: string }>): void { - labelNameToIds.clear(); +export function setLabelMap(accountId: string, labels: Array<{ id: string; name: string }>): void { + const map = new Map(); for (const label of labels) { - labelNameToIds.set(label.name.toLowerCase(), label.id); + map.set(label.name.toLowerCase(), label.id); } + labelNameToIdsByAccount.set(accountId, map); +} + +function resolveLabelNameToId(value: string): string | undefined { + const lower = value.toLowerCase(); + for (const map of labelNameToIdsByAccount.values()) { + const id = map.get(lower); + if (id) return id; + } + return undefined; } // Convert a glob-like pattern to a regex @@ -61,7 +71,7 @@ export function evaluateCondition(email: DashboardEmail, condition: InboxSplit[" if (labelIds.includes(condition.value)) { matches = true; } else { - const resolvedId = labelNameToIds.get(condition.value.toLowerCase()); + const resolvedId = resolveLabelNameToId(condition.value); matches = resolvedId ? labelIds.includes(resolvedId) : false; } break; From a20eec5075b6658c0a66f461697a002fc5330ff3 Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:12:08 -0700 Subject: [PATCH 3/7] =?UTF-8?q?Prefer=20account-specific=20labels=20in=20n?= =?UTF-8?q?ame=E2=86=92ID=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveLabelNameToId now accepts an optional accountId and checks that account's label map first. Falls back to searching all accounts only if the specific account has no match. Prevents wrong label IDs in multi-account setups where different accounts have same-named labels. --- src/renderer/utils/split-conditions.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/renderer/utils/split-conditions.ts b/src/renderer/utils/split-conditions.ts index 89282986..bdbaa5e8 100644 --- a/src/renderer/utils/split-conditions.ts +++ b/src/renderer/utils/split-conditions.ts @@ -12,8 +12,17 @@ export function setLabelMap(accountId: string, labels: Array<{ id: string; name: labelNameToIdsByAccount.set(accountId, map); } -function resolveLabelNameToId(value: string): string | undefined { +function resolveLabelNameToId(value: string, accountId?: string): string | undefined { const lower = value.toLowerCase(); + // Prefer the specific account's label map when available + if (accountId) { + const accountMap = labelNameToIdsByAccount.get(accountId); + if (accountMap) { + const id = accountMap.get(lower); + if (id) return id; + } + } + // Fall back to searching all accounts for (const map of labelNameToIdsByAccount.values()) { const id = map.get(lower); if (id) return id; @@ -71,7 +80,7 @@ export function evaluateCondition(email: DashboardEmail, condition: InboxSplit[" if (labelIds.includes(condition.value)) { matches = true; } else { - const resolvedId = resolveLabelNameToId(condition.value); + const resolvedId = resolveLabelNameToId(condition.value, email.accountId); matches = resolvedId ? labelIds.includes(resolvedId) : false; } break; From b2a314dae5b43e07b0d8582507fbe490d61c71ae Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:15:06 -0700 Subject: [PATCH 4/7] Add reactivity for label map updates via store version counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The label name→ID map is module-level state outside React's dependency tracking. When labels load asynchronously after initial render, split useMemo hooks would not recompute until an unrelated state change triggered a re-render. Add a labelMapVersion counter to the Zustand store, incremented when the label map is populated. Both SplitTabs and useSplitFilteredThreads include it in their useMemo dependencies, ensuring splits recompute when the label map becomes available. --- src/renderer/App.tsx | 4 +++- src/renderer/components/SplitTabs.tsx | 3 ++- src/renderer/store/index.ts | 7 ++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 68a73d53..19f8316d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -169,6 +169,7 @@ function SearchResultsView() { isOnline, remoteSearchNextPageToken, remoteSearchLoadingMore, + incrementLabelMapVersion, } = useAppStore(); const currentUserEmail = accounts.find(a => a.id === currentAccountId)?.email; @@ -588,9 +589,10 @@ export default function App() { window.api.labels.list(currentAccountId).then((result: { success: boolean; data?: Array<{ id: string; name: string }> }) => { if (result.success && result.data) { setLabelMap(currentAccountId, result.data); + incrementLabelMapVersion(); } }).catch(() => {}); - }, [currentAccountId]); + }, [currentAccountId, incrementLabelMapVersion]); // Initialize sync and accounts const initializeSync = useCallback(async () => { diff --git a/src/renderer/components/SplitTabs.tsx b/src/renderer/components/SplitTabs.tsx index 7845794a..590928a1 100644 --- a/src/renderer/components/SplitTabs.tsx +++ b/src/renderer/components/SplitTabs.tsx @@ -66,6 +66,7 @@ export function SplitTabs() { const archiveReadyThreadIds = useAppStore((state) => state.archiveReadyThreadIds); const recentlyUnsnoozedThreadIds = useAppStore((state) => state.recentlyUnsnoozedThreadIds); const localDrafts = useAppStore((state) => state.localDrafts); + const labelMapVersion = useAppStore((state) => state.labelMapVersion); const { threads, needsReply, done, snoozedCount } = useThreadedEmails(); // Filter splits for current account @@ -115,7 +116,7 @@ export function SplitTabs() { } return map; - }, [threads, needsReply, done, splits, isNonExclusive]); + }, [threads, needsReply, done, splits, isNonExclusive, labelMapVersion]); // Sort splits by order const sortedSplits = useMemo( diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index dc874fd6..3389aa6b 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -212,6 +212,7 @@ interface AppState { // Inbox splits state splits: InboxSplit[]; currentSplitId: string | null; + labelMapVersion: number; // incremented when label name→ID map is populated // Theme state themePreference: ThemePreference; @@ -376,6 +377,7 @@ interface AppState { // Inbox splits actions setSplits: (splits: InboxSplit[]) => void; setCurrentSplitId: (id: string | null) => void; + incrementLabelMapVersion: () => void; // Theme actions setThemePreference: (preference: ThemePreference) => void; @@ -538,6 +540,7 @@ export const useAppStore = create((set, get) => ({ // Inbox splits state splits: [], currentSplitId: "__priority__", + labelMapVersion: 0, // Theme state themePreference: "system", @@ -954,6 +957,7 @@ export const useAppStore = create((set, get) => ({ // Inbox splits actions setSplits: (splits) => set({ splits }), setCurrentSplitId: (id) => set({ currentSplitId: id }), + incrementLabelMapVersion: () => set((s) => ({ labelMapVersion: s.labelMapVersion + 1 })), // Theme actions setThemePreference: (preference) => set({ themePreference: preference }), @@ -1832,6 +1836,7 @@ export function useSplitFilteredThreads() { const recentlyUnsnoozedThreadIds = useAppStore((state) => state.recentlyUnsnoozedThreadIds); const unsnoozedReturnTimes = useAppStore((state) => state.unsnoozedReturnTimes); const sentEmails = useAppStore((state) => state.sentEmails); + const labelMapVersion = useAppStore((state) => state.labelMapVersion); return useMemo(() => { // Filter splits for current account @@ -2009,7 +2014,7 @@ export function useSplitFilteredThreads() { snoozed: baseResult.snoozed, snoozedCount: baseResult.snoozedCount, }; - }, [baseResult, allSplits, currentAccountId, accounts, currentSplitId, archiveReadyThreadIds, recentlyUnsnoozedThreadIds, unsnoozedReturnTimes, sentEmails]); + }, [baseResult, allSplits, currentAccountId, accounts, currentSplitId, archiveReadyThreadIds, recentlyUnsnoozedThreadIds, unsnoozedReturnTimes, sentEmails, labelMapVersion]); } // Legacy selector for backwards compatibility From 15893bb9ec73a9cfbdcbc167264239da5212e6a7 Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:26:13 -0700 Subject: [PATCH 5/7] Clean up review findings: remove unused destructure, log label fetch errors Ran gstack /review against the PR. All three Greptile P1/P2 comments were already addressed in commits 2-4. Two minor auto-fixes: - Remove unused `incrementLabelMapVersion` destructure from SearchResultsView - Replace silent `.catch(() => {})` with console.warn for label fetch errors Co-Authored-By: Claude Opus 4.6 (1M context) --- src/renderer/App.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 19f8316d..35bcef85 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -169,7 +169,6 @@ function SearchResultsView() { isOnline, remoteSearchNextPageToken, remoteSearchLoadingMore, - incrementLabelMapVersion, } = useAppStore(); const currentUserEmail = accounts.find(a => a.id === currentAccountId)?.email; @@ -591,7 +590,7 @@ export default function App() { setLabelMap(currentAccountId, result.data); incrementLabelMapVersion(); } - }).catch(() => {}); + }).catch((err) => console.warn("[Labels] Failed to fetch labels:", err)); }, [currentAccountId, incrementLabelMapVersion]); // Initialize sync and accounts From 269fd1959226a85664f57e9e052a73901ef3cc03 Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:40:58 -0700 Subject: [PATCH 6/7] Fix negated label conditions using .some() instead of .every() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For "NOT label X" conditions, .some() returns true if any email in the thread lacks the label (nearly always true). The correct semantic is .every() — all emails must lack the label for the thread to match. Also adds labelMapVersion to isNonExclusive deps so archiveReadyCount recomputes when the label map is populated. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/renderer/components/SplitTabs.tsx | 9 ++++++--- src/renderer/store/index.ts | 7 +++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/SplitTabs.tsx b/src/renderer/components/SplitTabs.tsx index 590928a1..2fef4fc8 100644 --- a/src/renderer/components/SplitTabs.tsx +++ b/src/renderer/components/SplitTabs.tsx @@ -11,9 +11,12 @@ function threadMatchesSplit(thread: EmailThread, split: InboxSplit): boolean { return emailMatchesSplit(thread.latestEmail, split); } - // Evaluate label conditions against ANY email in the thread (Gmail labels are thread-level) + // Evaluate label conditions against the thread (Gmail labels are thread-level). + // Negated conditions use .every() — "NOT label X" means no email has it. const labelResults = labelConditions.map((c) => - thread.emails.some((email) => evaluateCondition(email, c)) + c.negate + ? thread.emails.every((email) => evaluateCondition(email, c)) + : thread.emails.some((email) => evaluateCondition(email, c)) ); // Evaluate non-label conditions against only the latest email @@ -81,7 +84,7 @@ export function SplitTabs() { return (t: EmailThread) => recentlyUnsnoozedThreadIds.has(t.threadId) || !exclusiveSplits.some((s) => threadMatchesSplit(t, s)); - }, [splits, recentlyUnsnoozedThreadIds]); + }, [splits, recentlyUnsnoozedThreadIds, labelMapVersion]); const archiveReadyCount = useMemo( () => threads.filter( diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 3389aa6b..dbecbbe7 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -1807,9 +1807,12 @@ function threadMatchesSplit(thread: EmailThread, split: InboxSplit): boolean { return emailMatchesSplit(thread.latestEmail, split); } - // Evaluate label conditions against ANY email in the thread (Gmail labels are thread-level) + // Evaluate label conditions against the thread (Gmail labels are thread-level). + // Negated conditions use .every() — "NOT label X" means no email has it. const labelResults = labelConditions.map((c) => - thread.emails.some((email) => evaluateCondition(email, c)) + c.negate + ? thread.emails.every((email) => evaluateCondition(email, c)) + : thread.emails.some((email) => evaluateCondition(email, c)) ); // Evaluate non-label conditions against only the latest email From 03774c799561b150c588dab626c136a5af76c025 Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:53:26 -0700 Subject: [PATCH 7/7] Fix: add incrementLabelMapVersion to App component's useAppStore destructuring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The variable was used in App's useEffect (line 591) but only destructured in SearchResultsView (removed in the review cleanup commit as unused there). The .catch() silently swallowed the resulting ReferenceError, making the label map version counter never increment — breaking reactivity for label-based split conditions. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/renderer/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 35bcef85..6f31fe52 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -494,6 +494,7 @@ export default function App() { addSentEmails, setSplits, syncProgress, + incrementLabelMapVersion, } = useAppStore(); // Initialize keyboard shortcuts