@agent-native/core 0.168.0 → 0.168.2

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 (63) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/netlify.toml +4 -4
  3. package/corpus/templates/assets/netlify.toml +4 -1
  4. package/corpus/templates/brain/netlify.toml +4 -1
  5. package/corpus/templates/calendar/netlify.toml +4 -1
  6. package/corpus/templates/chat/netlify.toml +4 -1
  7. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +32 -0
  8. package/corpus/templates/clips/netlify.toml +4 -1
  9. package/corpus/templates/content/netlify.toml +4 -1
  10. package/corpus/templates/crm/netlify.toml +6 -1
  11. package/corpus/templates/design/netlify.toml +4 -1
  12. package/corpus/templates/dispatch/netlify.toml +4 -1
  13. package/corpus/templates/factory/netlify.toml +4 -1
  14. package/corpus/templates/forms/netlify.toml +4 -1
  15. package/corpus/templates/macros/netlify.toml +3 -3
  16. package/corpus/templates/mail/actions/view-screen.ts +80 -8
  17. package/corpus/templates/mail/app/components/email/EmailList.tsx +3 -1
  18. package/corpus/templates/mail/app/components/email/EmailListItem.tsx +1 -35
  19. package/corpus/templates/mail/app/components/email/EmailThread.tsx +16 -8
  20. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +146 -87
  21. package/corpus/templates/mail/app/global.css +21 -0
  22. package/corpus/templates/mail/app/hooks/use-emails.ts +6 -0
  23. package/corpus/templates/mail/app/hooks/use-navigation-state.ts +2 -0
  24. package/corpus/templates/mail/app/lib/inbox-tabs.ts +5 -0
  25. package/corpus/templates/mail/app/lib/label-colors.ts +47 -0
  26. package/corpus/templates/mail/app/pages/InboxPage.tsx +25 -8
  27. package/corpus/templates/mail/netlify.toml +4 -1
  28. package/corpus/templates/mail/server/handlers/emails.ts +27 -2
  29. package/corpus/templates/plan/netlify.toml +4 -1
  30. package/corpus/templates/slides/netlify.toml +4 -1
  31. package/corpus/templates/tasks/netlify.toml +6 -1
  32. package/dist/app-config/migration.d.ts +5 -0
  33. package/dist/app-config/migration.js +11 -0
  34. package/dist/app-config/schema.d.ts +4 -0
  35. package/dist/app-config/schema.js +2 -0
  36. package/dist/client/AgentPanel.d.ts +3 -1
  37. package/dist/client/AgentPanel.js +2 -2
  38. package/dist/client/agent-page/AgentTabsPage.d.ts +4 -1
  39. package/dist/client/agent-page/AgentTabsPage.js +2 -2
  40. package/dist/client/integrations/IntegrationConnectionChoice.d.ts +4 -1
  41. package/dist/client/integrations/IntegrationConnectionChoice.js +6 -2
  42. package/dist/client/onboarding/FirstRunOnboarding.js +9 -5
  43. package/dist/client/resources/McpIntegrationDialog.d.ts +3 -1
  44. package/dist/client/resources/McpIntegrationDialog.js +181 -112
  45. package/dist/client/resources/mcp-integration-catalog.d.ts +1 -0
  46. package/dist/client/resources/mcp-integration-catalog.js +5 -2
  47. package/dist/collab/routes.d.ts +1 -1
  48. package/dist/db/migrations.js +10 -2
  49. package/dist/deploy/build.js +6 -0
  50. package/dist/deploy/netlify-static-headers.d.ts +3 -0
  51. package/dist/deploy/netlify-static-headers.js +60 -0
  52. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  53. package/dist/localization/default-messages.d.ts +9 -0
  54. package/dist/localization/default-messages.js +9 -0
  55. package/dist/observability/routes.d.ts +6 -6
  56. package/dist/provider-api/actions/custom-provider-registration.d.ts +16 -16
  57. package/dist/provider-api/actions/provider-api.d.ts +17 -17
  58. package/dist/resources/handlers.d.ts +2 -2
  59. package/dist/server/transcribe-voice.d.ts +1 -1
  60. package/dist/templates/chat/netlify.toml +4 -1
  61. package/dist/vite/client.js +2 -0
  62. package/package.json +2 -1
  63. package/src/templates/chat/netlify.toml +4 -1
@@ -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 &&
@@ -43,6 +43,20 @@
43
43
  --mail-toast-surface: hsl(var(--card));
44
44
  --mail-toast-border: hsl(var(--border));
45
45
  --mail-overlay-scrim: hsl(0 0% 0% / 0.2);
46
+ --mail-label-automated-bg: 330 81% 60% / 0.2;
47
+ --mail-label-automated-text: 336 69% 36%;
48
+ --mail-label-social-bg: 217 91% 60% / 0.2;
49
+ --mail-label-social-text: 224 76% 48%;
50
+ --mail-label-updates-bg: 45 93% 47% / 0.2;
51
+ --mail-label-updates-text: 32 95% 44%;
52
+ --mail-label-promotions-bg: 142 71% 45% / 0.2;
53
+ --mail-label-promotions-text: 142 72% 29%;
54
+ --mail-label-forums-bg: 199 89% 48% / 0.2;
55
+ --mail-label-forums-text: 201 96% 32%;
56
+ --mail-label-finance-bg: 160 84% 39% / 0.2;
57
+ --mail-label-finance-text: 163 94% 24%;
58
+ --mail-label-travel-bg: 187 85% 53% / 0.2;
59
+ --mail-label-travel-text: 192 91% 36%;
46
60
  }
47
61
 
48
62
  .dark {
@@ -81,6 +95,13 @@
81
95
  --mail-toast-surface: hsl(0 0% 15%);
82
96
  --mail-toast-border: hsl(0 0% 26%);
83
97
  --mail-overlay-scrim: hsl(0 0% 6% / 0.32);
98
+ --mail-label-automated-text: 336 75% 75%;
99
+ --mail-label-social-text: 213 94% 68%;
100
+ --mail-label-updates-text: 48 96% 64%;
101
+ --mail-label-promotions-text: 141 76% 73%;
102
+ --mail-label-forums-text: 199 95% 74%;
103
+ --mail-label-finance-text: 152 76% 70%;
104
+ --mail-label-travel-text: 188 86% 74%;
84
105
  }
85
106
 
86
107
  @layer base {
@@ -1453,10 +1453,16 @@ export function useContacts() {
1453
1453
 
1454
1454
  // ─── Labels ──────────────────────────────────────────────────────────────────
1455
1455
 
1456
+ export const EMPTY_LABELS: Label[] = [];
1457
+
1456
1458
  export function useLabels() {
1457
1459
  return useQuery<Label[]>({
1458
1460
  queryKey: ["labels"],
1459
1461
  queryFn: () => apiFetch("/api/labels"),
1462
+ // A failed background refresh must not erase the last complete label map.
1463
+ // The layout still surfaces isError so an initial failure has an explicit
1464
+ // retry path instead of looking like an empty mailbox.
1465
+ placeholderData: (previousData) => previousData,
1460
1466
  staleTime: 60_000,
1461
1467
  });
1462
1468
  }
@@ -10,6 +10,8 @@ export interface NavigationState {
10
10
  selectedThreadIds?: string[];
11
11
  search?: string;
12
12
  label?: string;
13
+ activeInboxTab?: string;
14
+ activeAccounts?: string[];
13
15
  queuedDraftId?: string;
14
16
  queueScope?: string;
15
17
  settingsSection?: string;
@@ -24,6 +24,11 @@ export const COLLAPSIBLE_VIEW_IDS = [
24
24
  "trash",
25
25
  ] as const;
26
26
 
27
+ // Keep the synthetic remainder tab's UI identity separate from user-label IDs.
28
+ // Its public URL remains `tab=other` for existing links and agent commands.
29
+ export const OTHER_INBOX_TAB_ID = "__inbox_other__";
30
+ export const OTHER_INBOX_TAB_PARAM = "other";
31
+
27
32
  /** Pinned labels include a virtual "important" tab when Google is connected. */
28
33
  export function resolvePinnedLabels(
29
34
  userPinnedLabels: readonly string[],
@@ -0,0 +1,47 @@
1
+ export type LabelStyle = {
2
+ bg: string;
3
+ text: string;
4
+ };
5
+
6
+ const labelColors: Record<string, LabelStyle> = {
7
+ automated: {
8
+ bg: "bg-[hsl(var(--mail-label-automated-bg))]",
9
+ text: "text-[hsl(var(--mail-label-automated-text))]",
10
+ },
11
+ social: {
12
+ bg: "bg-[hsl(var(--mail-label-social-bg))]",
13
+ text: "text-[hsl(var(--mail-label-social-text))]",
14
+ },
15
+ updates: {
16
+ bg: "bg-[hsl(var(--mail-label-updates-bg))]",
17
+ text: "text-[hsl(var(--mail-label-updates-text))]",
18
+ },
19
+ promotions: {
20
+ bg: "bg-[hsl(var(--mail-label-promotions-bg))]",
21
+ text: "text-[hsl(var(--mail-label-promotions-text))]",
22
+ },
23
+ forums: {
24
+ bg: "bg-[hsl(var(--mail-label-forums-bg))]",
25
+ text: "text-[hsl(var(--mail-label-forums-text))]",
26
+ },
27
+ finance: {
28
+ bg: "bg-[hsl(var(--mail-label-finance-bg))]",
29
+ text: "text-[hsl(var(--mail-label-finance-text))]",
30
+ },
31
+ travel: {
32
+ bg: "bg-[hsl(var(--mail-label-travel-bg))]",
33
+ text: "text-[hsl(var(--mail-label-travel-text))]",
34
+ },
35
+ };
36
+
37
+ export function getLabelStyle(labelId: string): LabelStyle {
38
+ const normalized = labelId.toLowerCase().replace(/^label:/, "");
39
+ if (labelColors[normalized]) return labelColors[normalized];
40
+
41
+ let hash = 0;
42
+ for (let i = 0; i < normalized.length; i++) {
43
+ hash = normalized.charCodeAt(i) + ((hash << 5) - hash);
44
+ }
45
+ const options = Object.values(labelColors);
46
+ return options[Math.abs(hash) % options.length];
47
+ }
@@ -23,6 +23,7 @@ import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
23
23
  import { useIsMobile } from "@/hooks/use-mobile";
24
24
  import { useNavigationState } from "@/hooks/use-navigation-state";
25
25
  import {
26
+ OTHER_INBOX_TAB_PARAM,
26
27
  resolvePinnedLabels,
27
28
  pinnedTriageLabels,
28
29
  augmentSelfSentLabels,
@@ -260,6 +261,7 @@ export function InboxPage() {
260
261
  const { data: settings } = useSettings();
261
262
  const [searchParams] = useSearchParams();
262
263
  const activeLabel = searchParams.get("label");
264
+ const activeInboxTab = searchParams.get("tab");
263
265
  const routeSearchSuffix = searchParams.toString()
264
266
  ? `?${searchParams.toString()}`
265
267
  : "";
@@ -304,6 +306,10 @@ export function InboxPage() {
304
306
  view === "inbox" &&
305
307
  mailLabelsInclude(triageLabels, activeLabel);
306
308
  const clientSliceTab = isPinnedTab && !searchQuery;
309
+ const isOtherTab =
310
+ view === "inbox" &&
311
+ activeInboxTab === OTHER_INBOX_TAB_PARAM &&
312
+ !searchQuery;
307
313
  const effectiveLabel = clientSliceTab
308
314
  ? undefined
309
315
  : (activeLabel ?? undefined);
@@ -349,12 +355,7 @@ export function InboxPage() {
349
355
  return filterInboxTabEmails(filtered, activeLabel, pinnedLabels);
350
356
  }
351
357
  // "Other" tab — the inbox remainder, same partition as its badge.
352
- if (
353
- !searchQuery &&
354
- view === "inbox" &&
355
- !activeLabel &&
356
- triageLabels.length > 0
357
- ) {
358
+ if (isOtherTab) {
358
359
  return filterInboxTabEmails(filtered, null, pinnedLabels);
359
360
  }
360
361
 
@@ -407,6 +408,7 @@ export function InboxPage() {
407
408
  view,
408
409
  searchQuery,
409
410
  activeLabel,
411
+ isOtherTab,
410
412
  clientSliceTab,
411
413
  pinnedLabels,
412
414
  triageLabels,
@@ -419,7 +421,10 @@ export function InboxPage() {
419
421
  // Clear multi-selection when switching views or label tabs. Do NOT clear on
420
422
  // threadId changes — shift+j/k in detail view navigates between threads while
421
423
  // extending the selection, so selection must persist across thread nav.
422
- useEffect(() => setSelectedIds(new Set()), [view, activeLabel]);
424
+ useEffect(
425
+ () => setSelectedIds(new Set()),
426
+ [view, activeLabel, activeInboxTab],
427
+ );
423
428
 
424
429
  // Sync current navigation state to file (write-only, so agent can read it)
425
430
  const searchQ = searchParams.get("q") ?? undefined;
@@ -430,10 +435,22 @@ export function InboxPage() {
430
435
  focusedEmailId: focusedId ?? undefined,
431
436
  search: searchQ,
432
437
  label: activeLabel ?? undefined,
438
+ activeInboxTab: activeInboxTab ?? undefined,
439
+ activeAccounts:
440
+ activeAccounts.size > 0 ? Array.from(activeAccounts) : undefined,
433
441
  selectedThreadIds:
434
442
  selectedThreadIds.length > 0 ? selectedThreadIds : undefined,
435
443
  });
436
- }, [view, threadId, focusedId, searchQ, activeLabel, selectedThreadIds]); // eslint-disable-line react-hooks/exhaustive-deps
444
+ }, [
445
+ view,
446
+ threadId,
447
+ focusedId,
448
+ searchQ,
449
+ activeLabel,
450
+ activeInboxTab,
451
+ activeAccounts,
452
+ selectedThreadIds,
453
+ ]); // eslint-disable-line react-hooks/exhaustive-deps
437
454
 
438
455
  // One-shot agent navigation: agent writes navigate.json, UI reads it, navigates, deletes it
439
456
  const { data: navCommand } = navState.command;
@@ -1,6 +1,6 @@
1
1
  [build]
2
2
  ignore = "node ./scripts/netlify-ignore-build.mjs mail"
3
- command = "export DATABASE_URL=${NETLIFY_DATABASE_URL:-$DATABASE_URL} && pnpm install && DATABASE_URL=${NETLIFY_DATABASE_URL_UNPOOLED:-$DATABASE_URL} NITRO_PRESET=netlify pnpm --filter mail build && if [ \"${CONTEXT:-}\" = \"production\" ]; then DATABASE_URL=${NETLIFY_DATABASE_URL_UNPOOLED:-$DATABASE_URL} pnpm --filter mail migrate:production; fi"
3
+ command = "export DATABASE_URL=${NETLIFY_DATABASE_URL:-$DATABASE_URL} && pnpm install && DATABASE_URL=${NETLIFY_DATABASE_URL_UNPOOLED:-$DATABASE_URL} NITRO_PRESET=netlify pnpm --filter mail build && if [ \"${CONTEXT:-}\" = \"production\" ] || [ \"${AGENT_NATIVE_RUN_RELEASE_MIGRATIONS:-}\" = \"1\" ]; then DATABASE_URL=${NETLIFY_DATABASE_URL_UNPOOLED:-$DATABASE_URL} pnpm --filter mail migrate:production; fi"
4
4
  publish = "templates/mail/dist"
5
5
  functions = "templates/mail/.netlify/functions-internal"
6
6
 
@@ -14,6 +14,9 @@ NPM_CONFIG_PRODUCTION = "false"
14
14
  AGENT_NATIVE_HOSTED_HARNESS = "true"
15
15
  AGENT_NATIVE_ENABLE_KEEP_WARM = "1"
16
16
  AGENT_NATIVE_DISABLE_KEEP_WARM_BACKGROUND = "1"
17
+ # Production release migrations own schema setup; request functions must skip
18
+ # migration probes on every serverless invocation.
19
+ AGENT_NATIVE_RELEASE_MIGRATIONS = "1"
17
20
 
18
21
  # A2A delegation calls run a full agent loop (LLM + tool calls + DB queries).
19
22
  # The framework does not impose a default agent-run timeout; if a template sets