@agent-native/core 0.168.1 → 0.168.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/netlify.toml +4 -4
  3. package/corpus/templates/analytics/server/lib/dashboards-store.ts +32 -5
  4. package/corpus/templates/analytics/server/lib/scoped-settings.ts +2 -3
  5. package/corpus/templates/assets/netlify.toml +4 -1
  6. package/corpus/templates/brain/netlify.toml +4 -1
  7. package/corpus/templates/calendar/app/root.tsx +2 -0
  8. package/corpus/templates/calendar/netlify.toml +4 -1
  9. package/corpus/templates/chat/netlify.toml +4 -1
  10. package/corpus/templates/clips/desktop/src/app.tsx +4 -8
  11. package/corpus/templates/clips/netlify.toml +4 -1
  12. package/corpus/templates/clips/server/routes/_agent-native/google/auth-url.get.ts +17 -11
  13. package/corpus/templates/clips/server/routes/_agent-native/google/callback.get.ts +6 -5
  14. package/corpus/templates/content/netlify.toml +4 -1
  15. package/corpus/templates/crm/netlify.toml +6 -1
  16. package/corpus/templates/design/app/root.tsx +5 -1
  17. package/corpus/templates/design/netlify.toml +4 -1
  18. package/corpus/templates/dispatch/netlify.toml +4 -1
  19. package/corpus/templates/factory/netlify.toml +4 -1
  20. package/corpus/templates/forms/netlify.toml +4 -1
  21. package/corpus/templates/macros/netlify.toml +3 -3
  22. package/corpus/templates/mail/actions/view-screen.ts +80 -8
  23. package/corpus/templates/mail/app/components/email/EmailList.tsx +3 -1
  24. package/corpus/templates/mail/app/components/email/EmailListItem.tsx +1 -35
  25. package/corpus/templates/mail/app/components/email/EmailThread.tsx +16 -8
  26. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +146 -87
  27. package/corpus/templates/mail/app/global.css +21 -0
  28. package/corpus/templates/mail/app/hooks/use-emails.ts +6 -0
  29. package/corpus/templates/mail/app/hooks/use-navigation-state.ts +2 -0
  30. package/corpus/templates/mail/app/lib/inbox-tabs.ts +5 -0
  31. package/corpus/templates/mail/app/lib/label-colors.ts +47 -0
  32. package/corpus/templates/mail/app/pages/InboxPage.tsx +25 -8
  33. package/corpus/templates/mail/netlify.toml +4 -1
  34. package/corpus/templates/mail/server/handlers/emails.ts +27 -2
  35. package/corpus/templates/plan/netlify.toml +4 -1
  36. package/corpus/templates/slides/netlify.toml +4 -1
  37. package/corpus/templates/tasks/netlify.toml +6 -1
  38. package/dist/app-config/migration.d.ts +5 -0
  39. package/dist/app-config/migration.js +11 -0
  40. package/dist/app-config/schema.d.ts +4 -0
  41. package/dist/app-config/schema.js +2 -0
  42. package/dist/app-config/store.d.ts +8 -0
  43. package/dist/app-config/store.js +19 -1
  44. package/dist/collab/awareness.d.ts +2 -2
  45. package/dist/collab/routes.d.ts +1 -1
  46. package/dist/collab/struct-routes.d.ts +1 -1
  47. package/dist/db/migrations.js +10 -2
  48. package/dist/deploy/build.js +6 -0
  49. package/dist/deploy/netlify-static-headers.d.ts +3 -0
  50. package/dist/deploy/netlify-static-headers.js +60 -0
  51. package/dist/notifications/routes.d.ts +3 -3
  52. package/dist/observability/routes.d.ts +3 -3
  53. package/dist/provider-api/actions/custom-provider-registration.d.ts +16 -16
  54. package/dist/provider-api/actions/provider-api.d.ts +17 -17
  55. package/dist/server/transcribe-voice.d.ts +1 -1
  56. package/dist/settings/index.d.ts +1 -1
  57. package/dist/settings/index.js +1 -1
  58. package/dist/settings/org-settings.js +7 -3
  59. package/dist/templates/chat/netlify.toml +4 -1
  60. package/dist/vite/client.js +2 -0
  61. package/package.json +2 -1
  62. package/src/templates/chat/netlify.toml +4 -1
@@ -5,6 +5,12 @@ import { getSetting } from "@agent-native/core/settings";
5
5
  import { emailMessageMatchesSearch } from "@shared/search.js";
6
6
  import { z } from "zod";
7
7
 
8
+ import {
9
+ augmentSelfSentLabels,
10
+ filterInboxTabEmails,
11
+ OTHER_INBOX_TAB_PARAM,
12
+ resolvePinnedLabels,
13
+ } from "../app/lib/inbox-tabs.js";
8
14
  import { buildGmailEmailSearchQuery } from "../server/lib/gmail-query.js";
9
15
  import { gmailGetThread } from "../server/lib/google-api.js";
10
16
  import {
@@ -16,10 +22,12 @@ import {
16
22
  fetchGmailLabelMap,
17
23
  } from "../server/lib/google-auth.js";
18
24
  import { getSyntheticEmailsForView } from "../server/lib/jobs.js";
25
+ import { readSettings } from "../server/lib/mail-settings.js";
19
26
  import {
20
27
  listQueuedDrafts,
21
28
  requireQueuedDraft,
22
29
  } from "../server/lib/queued-drafts.js";
30
+ import type { EmailMessage } from "../shared/types.js";
23
31
  import { getAccessTokens, fetchLabelMap } from "./helpers.js";
24
32
 
25
33
  function latestPerThread(emails: any[]): any[] {
@@ -43,10 +51,33 @@ async function fetchEmailList(
43
51
  view: string,
44
52
  search?: string,
45
53
  _label?: string,
54
+ activeInboxTab?: string,
55
+ activeAccounts?: string[],
46
56
  ): Promise<any[]> {
47
57
  try {
48
58
  const ownerEmail = getRequestUserEmail();
49
59
  if (!ownerEmail) throw new Error("no authenticated user");
60
+ const shouldFilterOther =
61
+ view === "inbox" && !search && activeInboxTab === OTHER_INBOX_TAB_PARAM;
62
+ const selectedAccountEmails = Array.isArray(activeAccounts)
63
+ ? [
64
+ ...new Set(
65
+ activeAccounts
66
+ .filter((email): email is string => typeof email === "string")
67
+ .map((email) => email.toLowerCase()),
68
+ ),
69
+ ]
70
+ : [];
71
+ const selectedAccountSet = new Set(selectedAccountEmails);
72
+ const filterSelectedAccounts = (emails: any[]) => {
73
+ if (selectedAccountEmails.length === 0) return emails;
74
+ return emails.filter(
75
+ (email) =>
76
+ typeof email.accountEmail === "string" &&
77
+ selectedAccountSet.has(email.accountEmail.toLowerCase()),
78
+ );
79
+ };
80
+
50
81
  if (view === "snoozed" || view === "scheduled") {
51
82
  let emails = await getSyntheticEmailsForView(ownerEmail, view);
52
83
  if (search) {
@@ -54,10 +85,37 @@ async function fetchEmailList(
54
85
  emailMessageMatchesSearch(e, search),
55
86
  );
56
87
  }
57
- return emails.slice(0, 50);
88
+ return filterSelectedAccounts(emails).slice(0, 50);
58
89
  }
59
- if (await isConnected(ownerEmail)) {
60
- const clients = await getClients(ownerEmail);
90
+
91
+ const googleConnected = await isConnected(ownerEmail);
92
+ const settings =
93
+ googleConnected || shouldFilterOther
94
+ ? await readSettings(ownerEmail)
95
+ : undefined;
96
+ const userPinnedLabels = settings?.pinnedLabels;
97
+ const pinnedLabels =
98
+ userPinnedLabels === undefined
99
+ ? resolvePinnedLabels([], googleConnected)
100
+ : resolvePinnedLabels(userPinnedLabels, googleConnected);
101
+ const hasNoteToSelf = pinnedLabels.includes("note-to-self");
102
+ const clients = googleConnected ? await getClients(ownerEmail) : [];
103
+ const connectedEmails = new Set(
104
+ clients.map(({ email }) => email.toLowerCase()),
105
+ );
106
+ const prepareEmails = (emails: any[]) => {
107
+ const augmented = augmentSelfSentLabels(emails as EmailMessage[], {
108
+ isGoogleConnected: googleConnected,
109
+ connectedEmails,
110
+ hasNoteToSelf,
111
+ });
112
+ return filterSelectedAccounts(augmented);
113
+ };
114
+ const applyActiveInboxTab = (emails: any[]) =>
115
+ shouldFilterOther
116
+ ? filterInboxTabEmails(prepareEmails(emails), null, pinnedLabels)
117
+ : prepareEmails(emails);
118
+ if (googleConnected) {
61
119
  const labelMap = new Map<string, string>();
62
120
  await Promise.all(
63
121
  clients.map(async ({ accessToken }) => {
@@ -79,6 +137,10 @@ async function fetchEmailList(
79
137
  {
80
138
  mode: "threads",
81
139
  threadFormat: "metadata",
140
+ accountEmails:
141
+ selectedAccountEmails.length > 0
142
+ ? selectedAccountEmails
143
+ : undefined,
82
144
  threadCandidateLimit: search ? 500 : undefined,
83
145
  threadRecentMessageCandidateLimit:
84
146
  !search && (view === "inbox" || view === "unread")
@@ -87,9 +149,11 @@ async function fetchEmailList(
87
149
  },
88
150
  );
89
151
 
90
- return latestPerThread(
91
- messages.map((m: any) =>
92
- gmailToEmailMessage(m, m._accountEmail, labelMap),
152
+ return applyActiveInboxTab(
153
+ latestPerThread(
154
+ messages.map((m: any) =>
155
+ gmailToEmailMessage(m, m._accountEmail, labelMap),
156
+ ),
93
157
  ),
94
158
  ).slice(0, 50);
95
159
  }
@@ -136,7 +200,7 @@ async function fetchEmailList(
136
200
  emailMessageMatchesSearch(e, search),
137
201
  );
138
202
  }
139
- return emails.slice(0, 50);
203
+ return applyActiveInboxTab(emails).slice(0, 50);
140
204
  }
141
205
  return [];
142
206
  } catch {
@@ -255,7 +319,13 @@ export default defineAction({
255
319
  };
256
320
  }
257
321
  } else if (nav?.view) {
258
- const emails = await fetchEmailList(nav.view, nav.search, nav.label);
322
+ const emails = await fetchEmailList(
323
+ nav.view,
324
+ nav.search,
325
+ nav.label,
326
+ nav.activeInboxTab,
327
+ nav.activeAccounts,
328
+ );
259
329
  const selectedThreadIds = Array.isArray(nav.selectedThreadIds)
260
330
  ? new Set(
261
331
  nav.selectedThreadIds.filter(
@@ -279,6 +349,8 @@ export default defineAction({
279
349
  screen.emailList = {
280
350
  view: nav.view,
281
351
  label: nav.label ?? null,
352
+ activeInboxTab: nav.activeInboxTab ?? null,
353
+ activeAccounts: nav.activeAccounts ?? [],
282
354
  search: nav.search ?? null,
283
355
  selectedThreadIds: Array.from(selectedThreadIds),
284
356
  count: compact.length,
@@ -48,6 +48,7 @@ import {
48
48
  useBulkToggleStar,
49
49
  useBulkMarkRead,
50
50
  useLabels,
51
+ EMPTY_LABELS,
51
52
  useMoveEmail,
52
53
  unsuppressThread,
53
54
  } from "@/hooks/use-emails";
@@ -435,7 +436,8 @@ export function EmailList({
435
436
  const bulkTrashEmails = useBulkTrashEmails();
436
437
  const bulkToggleStar = useBulkToggleStar();
437
438
  const bulkMarkRead = useBulkMarkRead();
438
- const { data: labels = [] } = useLabels();
439
+ const { data: labelsData } = useLabels();
440
+ const labels = labelsData ?? EMPTY_LABELS;
439
441
  const moveEmail = useMoveEmail();
440
442
  const cancelScheduledJob = useDeleteScheduledJob();
441
443
  const sendScheduledJobNow = useSendScheduledJobNow();
@@ -21,6 +21,7 @@ import {
21
21
  TooltipTrigger,
22
22
  } from "@/components/ui/tooltip";
23
23
  import { useAccountFilter } from "@/hooks/use-account-filter";
24
+ import { getLabelStyle } from "@/lib/label-colors";
24
25
  import type { ThreadSummary } from "@/lib/threads";
25
26
  import { cn, formatEmailDate, truncate } from "@/lib/utils";
26
27
 
@@ -96,29 +97,6 @@ function formatParticipants(participants: string[], maxWidth = 3): string {
96
97
  return `${firstNames[0]} .. ${firstNames.slice(-(maxWidth - 1)).join(", ")}`;
97
98
  }
98
99
 
99
- // Map common label IDs to display colors
100
- const labelColors: Record<string, { bg: string; text: string }> = {
101
- automated: { bg: "bg-pink-500/20", text: "text-pink-700 dark:text-pink-300" },
102
- social: { bg: "bg-blue-500/20", text: "text-blue-700 dark:text-blue-300" },
103
- updates: {
104
- bg: "bg-yellow-500/20",
105
- text: "text-yellow-700 dark:text-yellow-300",
106
- },
107
- promotions: {
108
- bg: "bg-green-500/20",
109
- text: "text-green-700 dark:text-green-300",
110
- },
111
- forums: {
112
- bg: "bg-sky-500/20",
113
- text: "text-sky-700 dark:text-sky-300",
114
- },
115
- finance: {
116
- bg: "bg-emerald-500/20",
117
- text: "text-emerald-700 dark:text-emerald-300",
118
- },
119
- travel: { bg: "bg-cyan-500/20", text: "text-cyan-700 dark:text-cyan-300" },
120
- };
121
-
122
100
  /** Stable dot colors for distinguishing accounts */
123
101
  const accountDotColors = [
124
102
  "bg-blue-400",
@@ -139,18 +117,6 @@ function getAccountColor(
139
117
  return accountDotColors[(idx >= 0 ? idx : 0) % accountDotColors.length];
140
118
  }
141
119
 
142
- function getLabelStyle(labelId: string): { bg: string; text: string } {
143
- const normalized = labelId.toLowerCase().replace(/^label:/, "");
144
- if (labelColors[normalized]) return labelColors[normalized];
145
- // Fallback: hash to a color
146
- let hash = 0;
147
- for (let i = 0; i < normalized.length; i++) {
148
- hash = normalized.charCodeAt(i) + ((hash << 5) - hash);
149
- }
150
- const options = Object.values(labelColors);
151
- return options[Math.abs(hash) % options.length];
152
- }
153
-
154
120
  export const EmailListItem = memo(function EmailListItem({
155
121
  email,
156
122
  thread,
@@ -64,6 +64,7 @@ import {
64
64
  decodeHtmlEntities,
65
65
  processHtmlImages,
66
66
  } from "@/lib/email-image-policy";
67
+ import { getLabelStyle } from "@/lib/label-colors";
67
68
  import { isMcpEmbedSurface } from "@/lib/mcp-embed";
68
69
  import { getResolvedTheme } from "@/lib/theme";
69
70
  import { ensureThread, warmThreads } from "@/lib/thread-cache";
@@ -1138,14 +1139,21 @@ export function EmailThread({
1138
1139
  <h1 className="text-base sm:text-lg font-semibold leading-tight text-foreground line-clamp-2">
1139
1140
  {threadSubject}
1140
1141
  </h1>
1141
- {displayLabels.map((labelId) => (
1142
- <span
1143
- key={labelId}
1144
- className="label-badge shrink-0 bg-pink-500/20 text-pink-700 dark:text-pink-300 mt-1"
1145
- >
1146
- {labelId}
1147
- </span>
1148
- ))}
1142
+ {displayLabels.map((labelId) => {
1143
+ const style = getLabelStyle(labelId);
1144
+ return (
1145
+ <span
1146
+ key={labelId}
1147
+ className={cn(
1148
+ "label-badge shrink-0 mt-1",
1149
+ style.bg,
1150
+ style.text,
1151
+ )}
1152
+ >
1153
+ {labelId}
1154
+ </span>
1155
+ );
1156
+ })}
1149
1157
  {/* Action bar */}
1150
1158
  <div className="hidden sm:flex items-center gap-0.5 ml-auto shrink-0">
1151
1159
  <Tooltip>
@@ -34,6 +34,7 @@ import {
34
34
  IconMailForward,
35
35
  IconStar,
36
36
  IconTrash,
37
+ IconAlertCircle,
37
38
  } from "@tabler/icons-react";
38
39
  import { useQueryClient } from "@tanstack/react-query";
39
40
  import { useState, useCallback, useRef, useEffect, useMemo } from "react";
@@ -68,6 +69,7 @@ import {
68
69
  useBlockSender,
69
70
  useMuteThread,
70
71
  markExternalEmailRefresh,
72
+ EMPTY_LABELS,
71
73
  } from "@/hooks/use-emails";
72
74
  import {
73
75
  useGoogleAuthStatus,
@@ -81,6 +83,8 @@ import {
81
83
  import { useIsMobile } from "@/hooks/use-mobile";
82
84
  import { runUndo } from "@/hooks/use-undo";
83
85
  import {
86
+ OTHER_INBOX_TAB_ID,
87
+ OTHER_INBOX_TAB_PARAM,
84
88
  qualifiesForInboxTab,
85
89
  pinnedTriageLabels,
86
90
  augmentSelfSentLabels,
@@ -256,6 +260,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
256
260
  const [searchParams] = useSearchParams();
257
261
  const activeSearchQuery = searchParams.get("q");
258
262
  const activeLabel = searchParams.get("label");
263
+ const activeInboxTab = searchParams.get("tab");
259
264
  const composeInitialExpanded =
260
265
  searchParams.get(COMPOSE_FULLSCREEN_PARAM) === "1";
261
266
  const clearComposeInitialExpanded = useCallback(() => {
@@ -271,22 +276,31 @@ function AppLayoutInner({ children }: AppLayoutProps) {
271
276
  { replace: true },
272
277
  );
273
278
  }, [location.pathname, navigate, searchParams]);
274
- // Remember which view (and label tab) the user was in before searching —
279
+ // Remember which view (label or inbox tab) the user was in before searching —
275
280
  // SearchBar always routes searches through /all?q=..., so on clear we'd
276
281
  // otherwise drop a user searching from Starred/Sent/Archive or from a
277
282
  // label-filtered tab back into plain Inbox.
278
- const preSearchViewRef = useRef<{ view: string; label: string | null }>({
279
- view,
280
- label: activeLabel,
281
- });
283
+ const preSearchViewRef = useRef<{
284
+ view: string;
285
+ label: string | null;
286
+ tab: string | null;
287
+ }>({ view, label: activeLabel, tab: activeInboxTab });
282
288
  useEffect(() => {
283
289
  if (!activeSearchQuery) {
284
- preSearchViewRef.current = { view, label: activeLabel };
290
+ preSearchViewRef.current = {
291
+ view,
292
+ label: activeLabel,
293
+ tab: activeInboxTab,
294
+ };
285
295
  }
286
- }, [view, activeLabel, activeSearchQuery]);
296
+ }, [view, activeLabel, activeInboxTab, activeSearchQuery]);
287
297
  const restorePreSearchPath = useCallback(() => {
288
- const { view: v, label: l } = preSearchViewRef.current;
289
- return `/${v}${l ? `?label=${encodeURIComponent(l)}` : ""}`;
298
+ const { view: v, label: l, tab } = preSearchViewRef.current;
299
+ const params = new URLSearchParams();
300
+ if (l) params.set("label", l);
301
+ if (tab) params.set("tab", tab);
302
+ const search = params.toString();
303
+ return `/${v}${search ? `?${search}` : ""}`;
290
304
  }, []);
291
305
  // When the search param is cleared externally (browser back/forward,
292
306
  // agent navigation), drop the searchFocused flag — otherwise the bar
@@ -300,7 +314,15 @@ function AppLayoutInner({ children }: AppLayoutProps) {
300
314
  }
301
315
  prevSearchQueryRef.current = activeSearchQuery;
302
316
  }, [activeSearchQuery]);
303
- const { data: labels = [], isLoading: labelsLoading } = useLabels();
317
+ const {
318
+ data: labelsData,
319
+ isLoading: labelsLoading,
320
+ isError: labelsError,
321
+ error: labelsQueryError,
322
+ isFetching: labelsFetching,
323
+ refetch: refetchLabels,
324
+ } = useLabels();
325
+ const labels = labelsData ?? EMPTY_LABELS;
304
326
  const { data: settings, isLoading: settingsLoading } = useSettings();
305
327
  const updateSettings = useUpdateSettings();
306
328
  const googleStatus = useGoogleAuthStatus();
@@ -400,7 +422,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
400
422
  localStorage.removeItem(SIDEBAR_COLLAPSE_KEY);
401
423
  }
402
424
  }, [sidebarCollapsed]);
403
- const showSidebar = sidebarOpen || (sidebarPinned && !isMobile);
425
+ const showSidebar = isMobile ? sidebarOpen : sidebarOpen || sidebarPinned;
404
426
  const showCollapsedSidebar =
405
427
  !isMobile &&
406
428
  showSidebar &&
@@ -559,7 +581,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
559
581
  id: "inbox",
560
582
  label: t("mail.views.inbox"),
561
583
  href: "/inbox",
562
- isActive: view === "inbox" && !activeLabel,
584
+ isActive:
585
+ view === "inbox" &&
586
+ !activeLabel &&
587
+ activeInboxTab !== OTHER_INBOX_TAB_PARAM,
563
588
  type: "system",
564
589
  });
565
590
  }
@@ -615,10 +640,13 @@ function AppLayoutInner({ children }: AppLayoutProps) {
615
640
 
616
641
  if (hasPinnedFilters) {
617
642
  tabs.push({
618
- id: "inbox",
643
+ id: OTHER_INBOX_TAB_ID,
619
644
  label: t("mail.views.other"),
620
- href: "/inbox",
621
- isActive: view === "inbox" && !activeLabel,
645
+ href: `/inbox?tab=${OTHER_INBOX_TAB_PARAM}`,
646
+ isActive:
647
+ view === "inbox" &&
648
+ !activeLabel &&
649
+ activeInboxTab === OTHER_INBOX_TAB_PARAM,
622
650
  type: "system",
623
651
  });
624
652
  }
@@ -630,6 +658,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
630
658
  labelAliases,
631
659
  view,
632
660
  activeLabel,
661
+ activeInboxTab,
633
662
  hasPinnedFilters,
634
663
  t,
635
664
  ]);
@@ -661,6 +690,15 @@ function AppLayoutInner({ children }: AppLayoutProps) {
661
690
  [pinnedLabels],
662
691
  );
663
692
 
693
+ // The top-bar inbox tabs are hidden on mobile, so mirror their non-standard
694
+ // entries in the drawer. Collapsible system views already appear in the
695
+ // fixed drawer list below and must not be duplicated here.
696
+ const mobileInboxTabs = visibleTabs.filter(
697
+ (tab) =>
698
+ tab.id !== "inbox" &&
699
+ !collapsibleViews.some((view) => view.id === tab.id),
700
+ );
701
+
664
702
  // Is current view one of the hidden ones? If so force-show it
665
703
  const currentInHidden = hiddenViews.some((v) => v.id === view);
666
704
 
@@ -1010,6 +1048,15 @@ function AppLayoutInner({ children }: AppLayoutProps) {
1010
1048
 
1011
1049
  const useServerLabelCounts = activeAccounts.size === 0;
1012
1050
 
1051
+ // Gmail totals overlap across labels, while these tabs are an exclusive
1052
+ // partition of the loaded inbox. Keep their badges tied to that partition.
1053
+ const inboxPartitionTabIds = new Set<string>([OTHER_INBOX_TAB_ID]);
1054
+ for (const pinnedId of pinnedTriageLabels(pinnedLabels)) {
1055
+ inboxPartitionTabIds.add(pinnedId);
1056
+ const label = resolveLabelForCount(pinnedId);
1057
+ if (label) inboxPartitionTabIds.add(label.id);
1058
+ }
1059
+
1013
1060
  type CountKind = "unread" | "total";
1014
1061
  const countFieldForKind = (kind: CountKind) =>
1015
1062
  kind === "total" ? "totalCount" : "unreadCount";
@@ -1026,24 +1073,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
1026
1073
  const serverCount = useServerLabelCounts
1027
1074
  ? (inboxLabel?.[countField] ?? 0)
1028
1075
  : 0;
1029
- const localCount = localCounts["inbox"] ?? 0;
1076
+ const localCount = localCounts["__inboxTotal"] ?? 0;
1030
1077
  return Math.max(serverCount, localCount);
1031
1078
  };
1032
1079
 
1033
- const isExclusivePinnedTab = (viewId: string) => {
1034
- if (!hasPinnedFilters) return false;
1035
- // Pinned label rows (the ones that contribute to the "Other" remainder)
1036
- // are exclusive: each inbox thread is counted in exactly one tab. Gmail's
1037
- // server label counts don't have that exclusivity (e.g. server "important"
1038
- // returns *all* important threads, regardless of whether they're also
1039
- // categorized elsewhere), so we can't mix the two — use local only.
1040
- return pinnedLabels.some((id) => {
1041
- if (collapsibleViews.some((view) => view.id === id)) return false;
1042
- const label = resolveLabelForCount(id);
1043
- return (label?.id ?? id) === viewId || id === viewId;
1044
- });
1045
- };
1046
-
1047
1080
  const getOtherCount = (kind: CountKind) => {
1048
1081
  if (!hasPinnedFilters) return getInboxCount(kind);
1049
1082
  const localCounts = localCountsForKind(kind);
@@ -1056,20 +1089,18 @@ function AppLayoutInner({ children }: AppLayoutProps) {
1056
1089
  };
1057
1090
 
1058
1091
  const getTabCount = (viewId: string, kind: CountKind) => {
1059
- if (viewId === "inbox") return getOtherCount(kind);
1092
+ if (viewId === OTHER_INBOX_TAB_ID) return getOtherCount(kind);
1093
+ if (viewId === "inbox") return getInboxCount(kind);
1060
1094
  const label = resolveLabelForCount(viewId);
1061
1095
  const countField = countFieldForKind(kind);
1062
1096
  const localCounts = localCountsForKind(kind);
1063
1097
  const localCount =
1064
1098
  localCounts[viewId] ?? (label ? (localCounts[label.id] ?? 0) : 0);
1065
- // Exclusive pinned tabs (when hasPinnedFilters is on, a thread belongs to
1066
- // exactly one tab) can't fall back to Gmail's non-exclusive server count
1067
- // — that would over-report the badge relative to what the tab renders.
1068
- if (isExclusivePinnedTab(viewId)) return localCount;
1069
1099
  const serverCount =
1070
1100
  useServerLabelCounts && viewId !== "note-to-self"
1071
1101
  ? (label?.[countField] ?? 0)
1072
1102
  : 0;
1103
+ if (inboxPartitionTabIds.has(viewId)) return localCount;
1073
1104
  return Math.max(serverCount, localCount);
1074
1105
  };
1075
1106
  const getTopBarCount = (viewId: string) => getTabCount(viewId, "total");
@@ -1158,7 +1189,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
1158
1189
  {/* Primary tabs stay mounted during search so navigation does not jump. */}
1159
1190
  <>
1160
1191
  {tabsLoading ? (
1161
- <nav className="flex items-center gap-2 overflow-x-auto hide-scrollbar">
1192
+ <nav className="hidden sm:flex items-center gap-2 overflow-x-auto hide-scrollbar">
1162
1193
  {[1, 2, 3].map((i) => (
1163
1194
  <span
1164
1195
  key={i}
@@ -1168,7 +1199,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
1168
1199
  ))}
1169
1200
  </nav>
1170
1201
  ) : (
1171
- <nav className="flex min-w-0 items-center gap-0.5 overflow-x-auto hide-scrollbar">
1202
+ <nav className="hidden sm:flex min-w-0 items-center gap-0.5 overflow-x-auto hide-scrollbar">
1172
1203
  {topBarTabs.map((tab, idx) => {
1173
1204
  const visibleIndex = visibleTabs.findIndex(
1174
1205
  (item) => item.id === tab.id,
@@ -1252,7 +1283,12 @@ function AppLayoutInner({ children }: AppLayoutProps) {
1252
1283
  )}
1253
1284
 
1254
1285
  {/* Tab settings cog */}
1255
- <div className={cn("relative", tabsLoading && "invisible")}>
1286
+ <div
1287
+ className={cn(
1288
+ "relative hidden sm:block",
1289
+ tabsLoading && "invisible",
1290
+ )}
1291
+ >
1256
1292
  <Popover
1257
1293
  open={tabSettingsOpen}
1258
1294
  onOpenChange={(open) => {
@@ -1531,7 +1567,12 @@ function AppLayoutInner({ children }: AppLayoutProps) {
1531
1567
  <button
1532
1568
  type="button"
1533
1569
  onClick={() => {
1534
- setSidebarPinned((value) => !value);
1570
+ if (sidebarPinned) {
1571
+ setSidebarPinned(false);
1572
+ setSidebarOpen(!isMobile);
1573
+ return;
1574
+ }
1575
+ setSidebarPinned(true);
1535
1576
  setSidebarOpen(true);
1536
1577
  }}
1537
1578
  className={cn(
@@ -1726,63 +1767,56 @@ function AppLayoutInner({ children }: AppLayoutProps) {
1726
1767
  ))}
1727
1768
  </div>
1728
1769
 
1729
- {/* Pinned labels */}
1730
- {pinnedLabels.filter(
1731
- (l) => !collapsibleViews.some((v) => v.id === l),
1732
- ).length > 0 && (
1770
+ {/* Mobile equivalents for the hidden top-bar inbox tabs */}
1771
+ {mobileInboxTabs.length > 0 && (
1733
1772
  <>
1734
1773
  <h2 className="text-[11px] font-medium text-muted-foreground/50 uppercase tracking-wider mt-5 mb-3">
1735
1774
  {t("mail.views.labels")}
1736
1775
  </h2>
1737
1776
  <div className="space-y-0.5">
1738
- {visibleTabs
1739
- .filter(
1740
- (t) => t.id !== "inbox" && t.type === "label",
1741
- )
1742
- .map((tab) => {
1743
- const count = getUnreadCount(tab.id);
1744
- const depth = labelDepth(
1745
- tab.fullLabel ?? tab.label,
1746
- );
1747
- return (
1748
- <Link
1749
- key={tab.id}
1750
- to={tab.href}
1751
- onClick={closeSidebar}
1752
- className={cn(
1753
- "flex items-center justify-between rounded-md px-3 py-2.5 text-[14px] transition-colors min-h-[44px]",
1754
- tab.isActive
1755
- ? "bg-accent/60 text-foreground font-medium"
1756
- : "text-foreground/70 hover:bg-accent/30",
1757
- )}
1777
+ {mobileInboxTabs.map((tab) => {
1778
+ const count = getUnreadCount(tab.id);
1779
+ const depth =
1780
+ tab.type === "label"
1781
+ ? labelDepth(tab.fullLabel ?? tab.label)
1782
+ : 0;
1783
+ return (
1784
+ <Link
1785
+ key={tab.id}
1786
+ to={tab.href}
1787
+ onClick={closeSidebar}
1788
+ className={cn(
1789
+ "flex items-center justify-between rounded-md px-3 py-2.5 text-[14px] transition-colors min-h-[44px]",
1790
+ tab.isActive
1791
+ ? "bg-accent/60 text-foreground font-medium"
1792
+ : "text-foreground/70 hover:bg-accent/30",
1793
+ )}
1794
+ >
1795
+ <span
1796
+ className="flex min-w-0 items-center gap-2"
1797
+ style={{ paddingLeft: depth * 12 }}
1758
1798
  >
1799
+ {tab.color && (
1800
+ <span
1801
+ className="h-2 w-2 rounded-full shrink-0"
1802
+ style={{ backgroundColor: tab.color }}
1803
+ />
1804
+ )}
1759
1805
  <span
1760
- className="flex min-w-0 items-center gap-2"
1761
- style={{ paddingLeft: depth * 12 }}
1806
+ className="truncate"
1807
+ title={tab.fullLabel ?? tab.label}
1762
1808
  >
1763
- {tab.color && (
1764
- <span
1765
- className="h-2 w-2 rounded-full shrink-0"
1766
- style={{ backgroundColor: tab.color }}
1767
- />
1768
- )}
1769
- <span
1770
- className="truncate"
1771
- title={tab.fullLabel}
1772
- >
1773
- {shortLabelName(
1774
- tab.fullLabel ?? tab.label,
1775
- )}
1776
- </span>
1809
+ {tab.label}
1777
1810
  </span>
1778
- {count > 0 && (
1779
- <span className="text-[12px] text-muted-foreground/50 tabular-nums">
1780
- {count}
1781
- </span>
1782
- )}
1783
- </Link>
1784
- );
1785
- })}
1811
+ </span>
1812
+ {count > 0 && (
1813
+ <span className="text-[12px] text-muted-foreground/50 tabular-nums">
1814
+ {count}
1815
+ </span>
1816
+ )}
1817
+ </Link>
1818
+ );
1819
+ })}
1786
1820
  </div>
1787
1821
  </>
1788
1822
  )}
@@ -1840,6 +1874,31 @@ function AppLayoutInner({ children }: AppLayoutProps) {
1840
1874
  >
1841
1875
  <InvitationBanner />
1842
1876
 
1877
+ {labelsError && (
1878
+ <div
1879
+ role="alert"
1880
+ className="flex shrink-0 items-center gap-2 border-b border-destructive/20 bg-destructive/5 px-4 py-2 text-xs text-muted-foreground"
1881
+ >
1882
+ <IconAlertCircle className="h-3.5 w-3.5 shrink-0 text-destructive" />
1883
+ <span className="min-w-0 flex-1 truncate">
1884
+ {labelsQueryError instanceof Error && labelsQueryError.message
1885
+ ? labelsQueryError.message
1886
+ : t("mail.error.loadTitle")}
1887
+ </span>
1888
+ <Button
1889
+ type="button"
1890
+ size="sm"
1891
+ variant="ghost"
1892
+ disabled={labelsFetching}
1893
+ onClick={() => void refetchLabels()}
1894
+ >
1895
+ {labelsFetching
1896
+ ? t("mail.error.retrying")
1897
+ : t("mail.error.tryAgain")}
1898
+ </Button>
1899
+ </div>
1900
+ )}
1901
+
1843
1902
  {/* Show full-page takeover when no accounts connected (except on settings page) */}
1844
1903
  {!googleStatus.isLoading &&
1845
1904
  !googleStatus.isError &&