@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/netlify.toml +4 -4
- package/corpus/templates/assets/netlify.toml +4 -1
- package/corpus/templates/brain/netlify.toml +4 -1
- package/corpus/templates/calendar/netlify.toml +4 -1
- package/corpus/templates/chat/netlify.toml +4 -1
- package/corpus/templates/clips/desktop/src-tauri/src/util.rs +32 -0
- package/corpus/templates/clips/netlify.toml +4 -1
- package/corpus/templates/content/netlify.toml +4 -1
- package/corpus/templates/crm/netlify.toml +6 -1
- package/corpus/templates/design/netlify.toml +4 -1
- package/corpus/templates/dispatch/netlify.toml +4 -1
- package/corpus/templates/factory/netlify.toml +4 -1
- package/corpus/templates/forms/netlify.toml +4 -1
- package/corpus/templates/macros/netlify.toml +3 -3
- package/corpus/templates/mail/actions/view-screen.ts +80 -8
- package/corpus/templates/mail/app/components/email/EmailList.tsx +3 -1
- package/corpus/templates/mail/app/components/email/EmailListItem.tsx +1 -35
- package/corpus/templates/mail/app/components/email/EmailThread.tsx +16 -8
- package/corpus/templates/mail/app/components/layout/AppLayout.tsx +146 -87
- package/corpus/templates/mail/app/global.css +21 -0
- package/corpus/templates/mail/app/hooks/use-emails.ts +6 -0
- package/corpus/templates/mail/app/hooks/use-navigation-state.ts +2 -0
- package/corpus/templates/mail/app/lib/inbox-tabs.ts +5 -0
- package/corpus/templates/mail/app/lib/label-colors.ts +47 -0
- package/corpus/templates/mail/app/pages/InboxPage.tsx +25 -8
- package/corpus/templates/mail/netlify.toml +4 -1
- package/corpus/templates/mail/server/handlers/emails.ts +27 -2
- package/corpus/templates/plan/netlify.toml +4 -1
- package/corpus/templates/slides/netlify.toml +4 -1
- package/corpus/templates/tasks/netlify.toml +6 -1
- package/dist/app-config/migration.d.ts +5 -0
- package/dist/app-config/migration.js +11 -0
- package/dist/app-config/schema.d.ts +4 -0
- package/dist/app-config/schema.js +2 -0
- package/dist/client/AgentPanel.d.ts +3 -1
- package/dist/client/AgentPanel.js +2 -2
- package/dist/client/agent-page/AgentTabsPage.d.ts +4 -1
- package/dist/client/agent-page/AgentTabsPage.js +2 -2
- package/dist/client/integrations/IntegrationConnectionChoice.d.ts +4 -1
- package/dist/client/integrations/IntegrationConnectionChoice.js +6 -2
- package/dist/client/onboarding/FirstRunOnboarding.js +9 -5
- package/dist/client/resources/McpIntegrationDialog.d.ts +3 -1
- package/dist/client/resources/McpIntegrationDialog.js +181 -112
- package/dist/client/resources/mcp-integration-catalog.d.ts +1 -0
- package/dist/client/resources/mcp-integration-catalog.js +5 -2
- package/dist/collab/routes.d.ts +1 -1
- package/dist/db/migrations.js +10 -2
- package/dist/deploy/build.js +6 -0
- package/dist/deploy/netlify-static-headers.d.ts +3 -0
- package/dist/deploy/netlify-static-headers.js +60 -0
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/localization/default-messages.d.ts +9 -0
- package/dist/localization/default-messages.js +9 -0
- package/dist/observability/routes.d.ts +6 -6
- package/dist/provider-api/actions/custom-provider-registration.d.ts +16 -16
- package/dist/provider-api/actions/provider-api.d.ts +17 -17
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/chat/netlify.toml +4 -1
- package/dist/vite/client.js +2 -0
- package/package.json +2 -1
- 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 (
|
|
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<{
|
|
279
|
-
view
|
|
280
|
-
label:
|
|
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 = {
|
|
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
|
-
|
|
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 {
|
|
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 ||
|
|
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:
|
|
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:
|
|
643
|
+
id: OTHER_INBOX_TAB_ID,
|
|
619
644
|
label: t("mail.views.other"),
|
|
620
|
-
href:
|
|
621
|
-
isActive:
|
|
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["
|
|
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 ===
|
|
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
|
|
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
|
-
|
|
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
|
-
{/*
|
|
1730
|
-
{
|
|
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
|
-
{
|
|
1739
|
-
.
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
"
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
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="
|
|
1761
|
-
|
|
1806
|
+
className="truncate"
|
|
1807
|
+
title={tab.fullLabel ?? tab.label}
|
|
1762
1808
|
>
|
|
1763
|
-
{tab.
|
|
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
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
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
|
}
|
|
@@ -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(
|
|
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
|
-
}, [
|
|
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
|