@agent-native/core 0.161.20 → 0.161.23

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 (57) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/actions/finalize-meeting.ts +57 -21
  3. package/corpus/templates/clips/actions/get-meeting.ts +11 -3
  4. package/corpus/templates/clips/actions/request-transcript.ts +183 -8
  5. package/corpus/templates/clips/actions/update-meeting.ts +66 -14
  6. package/corpus/templates/clips/app/components/meetings/canvas-editor.tsx +80 -135
  7. package/corpus/templates/clips/app/i18n/en-US.ts +4 -0
  8. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +470 -35
  9. package/corpus/templates/clips/changelog/2026-08-18-meeting-notes-support-rich-editing-for-personal-notes-summar.md +6 -0
  10. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +13 -3
  11. package/corpus/templates/design/app/components/design/keyboard-shortcuts.ts +13 -7
  12. package/corpus/templates/design/app/hooks/use-navigation-state.ts +18 -21
  13. package/corpus/templates/design/app/pages/design-editor/editor-state.ts +2 -5
  14. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +4 -12
  15. package/corpus/templates/design/app/pages/design-editor/types.ts +38 -1
  16. package/corpus/templates/design/changelog/2026-08-18-advanced-design-editor-panels-hidden-by-default.md +6 -0
  17. package/corpus/templates/design/playwright.config.ts +19 -2
  18. package/corpus/templates/mail/app/components/email/EmailList.tsx +74 -22
  19. package/corpus/templates/mail/app/components/email/infinite-pagination.ts +82 -0
  20. package/corpus/templates/mail/app/hooks/use-emails.ts +1 -0
  21. package/corpus/templates/mail/app/pages/InboxPage.tsx +15 -10
  22. package/corpus/templates/mail/app/pages/inbox-zero.ts +35 -0
  23. package/corpus/templates/mail/changelog/2026-08-18-pinned-labels-load-matches.md +6 -0
  24. package/corpus/templates/slides/actions/get-deck.ts +5 -1
  25. package/corpus/templates/slides/actions/list-decks.ts +29 -8
  26. package/corpus/templates/slides/actions/view-screen.ts +7 -3
  27. package/corpus/templates/slides/changelog/2026-08-18-decks-appear-under-mine.md +6 -0
  28. package/corpus/templates/slides/shared/ownership.ts +6 -0
  29. package/dist/agent/engine/builder-engine.js +15 -8
  30. package/dist/agent/production-agent.d.ts +15 -0
  31. package/dist/agent/production-agent.js +32 -9
  32. package/dist/app-config/schema.d.ts +2 -0
  33. package/dist/app-config/workspace.d.ts +2 -0
  34. package/dist/app-config/workspace.js +18 -0
  35. package/dist/catalog.json +23 -23
  36. package/dist/client/analytics.d.ts +1 -0
  37. package/dist/client/api-path.js +7 -3
  38. package/dist/client/frame.js +20 -6
  39. package/dist/client/org/workspace-app-links.js +10 -1
  40. package/dist/collab/awareness.d.ts +2 -2
  41. package/dist/collab/routes.d.ts +1 -1
  42. package/dist/deploy/build.js +26 -2
  43. package/dist/observability/routes.d.ts +2 -2
  44. package/dist/provider-api/actions/custom-provider-registration.d.ts +14 -14
  45. package/dist/provider-api/actions/provider-api.d.ts +15 -15
  46. package/dist/server/app-origin-config.d.ts +1 -0
  47. package/dist/server/app-origin-config.js +3 -0
  48. package/dist/server/auth.js +2 -2
  49. package/dist/server/cookie-namespace.js +17 -2
  50. package/dist/server/credential-provider.js +5 -2
  51. package/dist/server/derived-secret.js +3 -2
  52. package/dist/server/onboarding-html.js +34 -2
  53. package/dist/server/workspace-oauth.js +11 -6
  54. package/dist/shared/runtime-config.d.ts +2 -0
  55. package/dist/shared/runtime-config.js +12 -3
  56. package/dist/vite/client.js +5 -2
  57. package/package.json +2 -2
@@ -1,4 +1,5 @@
1
1
  import type { UseDesignHotkeysProps } from "@/hooks/useDesignHotkeys";
2
+ import { SHOW_DESIGN_SECONDARY_LEFT_PANELS } from "@/pages/design-editor/types";
2
3
 
3
4
  export const DESIGN_SHORTCUT_CATEGORIES = [
4
5
  "essential",
@@ -136,13 +137,18 @@ export const DESIGN_SHORTCUTS: readonly DesignShortcutDefinition[] = [
136
137
  labelKey: "designEditor.keyboardShortcuts.commands.showLayers",
137
138
  handler: "onShowLayersPanel",
138
139
  }),
139
- shortcut({
140
- id: "show-assets",
141
- category: "view",
142
- bindings: ["alt+2"],
143
- labelKey: "designEditor.keyboardShortcuts.commands.showAssets",
144
- handler: "onShowAssetsPanel",
145
- }),
140
+ ...(SHOW_DESIGN_SECONDARY_LEFT_PANELS
141
+ ? [
142
+ shortcut({
143
+ id: "show-assets",
144
+ category: "view" as const,
145
+ bindings: ["alt+2"],
146
+ labelKey:
147
+ "designEditor.keyboardShortcuts.commands.showAssets" as const,
148
+ handler: "onShowAssetsPanel" as const,
149
+ }),
150
+ ]
151
+ : []),
146
152
  shortcut({
147
153
  id: "toggle-ui",
148
154
  category: "view",
@@ -6,6 +6,8 @@ import { useAgentRouteState } from "@agent-native/core/client/navigation";
6
6
  import { useEffect } from "react";
7
7
  import { useLocation, useParams } from "react-router";
8
8
 
9
+ import { normalizeDesignLeftPanel } from "@/pages/design-editor/tool-state";
10
+
9
11
  export interface NavigationState {
10
12
  view: string;
11
13
  designId?: string;
@@ -120,12 +122,11 @@ function normalizeEditorView(
120
122
 
121
123
  function normalizeInspectorTab(
122
124
  value: unknown,
123
- ): "design" | "comments" | "tweaks" | "code" | "extensions" | undefined {
125
+ ): "design" | "comments" | "tweaks" | "code" | undefined {
124
126
  return value === "design" ||
125
127
  value === "comments" ||
126
128
  value === "tweaks" ||
127
- value === "code" ||
128
- value === "extensions"
129
+ value === "code"
129
130
  ? value
130
131
  : undefined;
131
132
  }
@@ -141,16 +142,7 @@ function normalizeLeftPanel(
141
142
  | "import"
142
143
  | "code"
143
144
  | undefined {
144
- if (value === "extensions") return "tools";
145
- return value === "file" ||
146
- value === "agent" ||
147
- value === "assets" ||
148
- value === "tools" ||
149
- value === "tokens" ||
150
- value === "import" ||
151
- value === "code"
152
- ? value
153
- : undefined;
145
+ return normalizeDesignLeftPanel(value);
154
146
  }
155
147
 
156
148
  function normalizeEditorMode(
@@ -176,9 +168,12 @@ export function editorPathFromCommand(cmd: NavigationState): string | null {
176
168
  const editorView = normalizeEditorView(cmd.editorView);
177
169
  if (editorView) params.set("view", editorView);
178
170
  if (editorView === "single") params.set("mode", cmd.mode ?? "interact");
179
- const inspectorTab = normalizeInspectorTab(cmd.inspectorTab ?? cmd.inspector);
171
+ const rawInspectorTab = cmd.inspectorTab ?? cmd.inspector;
172
+ const inspectorTab = normalizeInspectorTab(rawInspectorTab);
180
173
  if (inspectorTab) params.set("inspector", inspectorTab);
181
- const leftPanel = normalizeLeftPanel(cmd.leftPanel ?? cmd.panel);
174
+ const leftPanel = normalizeLeftPanel(
175
+ cmd.leftPanel ?? cmd.panel ?? rawInspectorTab,
176
+ );
182
177
  if (leftPanel) params.set("panel", leftPanel);
183
178
  const screen = cmd.fileId ?? cmd.screenId ?? cmd.filename ?? cmd.screen;
184
179
  if (screen) params.set("screen", screen);
@@ -201,10 +196,11 @@ export function editorCommandFromNavigate(
201
196
  ): DesignEditorCommand | null {
202
197
  if (cmd.view !== "editor" || !cmd.designId) return null;
203
198
  const editorView = normalizeEditorView(cmd.editorView);
204
- const inspectorTab = normalizeInspectorTab(cmd.inspectorTab ?? cmd.inspector);
199
+ const rawInspectorTab = cmd.inspectorTab ?? cmd.inspector;
200
+ const inspectorTab = normalizeInspectorTab(rawInspectorTab);
205
201
  const leftPanel =
206
202
  normalizeLeftPanel(cmd.leftPanel ?? cmd.panel) ??
207
- normalizeLeftPanel(cmd.inspectorTab ?? cmd.inspector);
203
+ normalizeLeftPanel(rawInspectorTab);
208
204
  const command: DesignEditorCommand = {
209
205
  designId: cmd.designId,
210
206
  issuedAt: Date.now(),
@@ -255,11 +251,12 @@ export function useNavigationState(enabled = true) {
255
251
  if (editorView) state.editorView = editorView;
256
252
  const mode = normalizeEditorMode(searchParams.get("mode"));
257
253
  if (mode) state.mode = mode;
258
- const inspectorTab = normalizeInspectorTab(
259
- searchParams.get("inspector"),
260
- );
254
+ const rawInspectorTab = searchParams.get("inspector");
255
+ const inspectorTab = normalizeInspectorTab(rawInspectorTab);
261
256
  if (inspectorTab) state.inspectorTab = inspectorTab;
262
- const leftPanel = normalizeLeftPanel(searchParams.get("panel"));
257
+ const leftPanel = normalizeLeftPanel(
258
+ searchParams.get("panel") ?? rawInspectorTab,
259
+ );
263
260
  if (leftPanel) state.leftPanel = leftPanel;
264
261
  const screen = searchParams.get("screen");
265
262
  if (screen) state.screen = screen;
@@ -1,11 +1,11 @@
1
1
  import { isStandaloneHttpUrl } from "@shared/html-content";
2
2
 
3
+ import { normalizeDesignLeftPanel } from "./tool-state";
3
4
  import {
4
5
  type DesignFile,
5
6
  type DesignLeftPanel,
6
7
  type DesignTool,
7
8
  type EditorMode,
8
- SHOW_DESIGN_CODE_LEFT_PANEL,
9
9
  } from "./types";
10
10
 
11
11
  // Definition moved to shared/ so the server write path and code-layer's
@@ -127,10 +127,7 @@ export function getDesignEditorStateUrlSearch(args: {
127
127
  mode?: EditorMode | null;
128
128
  }) {
129
129
  const params = new URLSearchParams(args.currentSearch);
130
- const leftPanel =
131
- args.leftPanel === "code" && !SHOW_DESIGN_CODE_LEFT_PANEL
132
- ? null
133
- : args.leftPanel;
130
+ const leftPanel = normalizeDesignLeftPanel(args.leftPanel) ?? null;
134
131
  params.set("view", args.viewMode);
135
132
  if (leftPanel && leftPanel !== "file") {
136
133
  params.set("panel", leftPanel);
@@ -1,7 +1,7 @@
1
1
  import type { CreationTool } from "@/components/design/design-canvas/creation";
2
2
 
3
3
  import {
4
- SHOW_DESIGN_CODE_LEFT_PANEL,
4
+ isDesignLeftPanelEnabled,
5
5
  type DesignLeftPanel,
6
6
  type DesignTool,
7
7
  type EditorMode,
@@ -10,18 +10,10 @@ import {
10
10
  export function normalizeDesignLeftPanel(
11
11
  value: unknown,
12
12
  ): DesignLeftPanel | undefined {
13
- if (value === "extensions") return "tools";
14
- if (value === "code") {
15
- return SHOW_DESIGN_CODE_LEFT_PANEL ? "code" : undefined;
13
+ if (value === "extensions") {
14
+ return isDesignLeftPanelEnabled("tools") ? "tools" : undefined;
16
15
  }
17
- return value === "file" ||
18
- value === "agent" ||
19
- value === "assets" ||
20
- value === "tools" ||
21
- value === "tokens" ||
22
- value === "import"
23
- ? value
24
- : undefined;
16
+ return isDesignLeftPanelEnabled(value) ? value : undefined;
25
17
  }
26
18
 
27
19
  export const MOVE_GROUP_TOOL_PRESENTATIONS = {
@@ -33,7 +33,44 @@ export type DesignLeftPanel =
33
33
  | "import"
34
34
  | "code";
35
35
 
36
- export const SHOW_DESIGN_CODE_LEFT_PANEL = true;
36
+ /**
37
+ * Keep the still-evolving Design editor side panels out of the default
38
+ * surface until their Figma parity work is ready. Flip this switch to expose
39
+ * Assets, Tools, Tokens, and Code together while iterating on them. The E2E
40
+ * harness sets the Vite flag so those existing advanced-surface tests keep
41
+ * exercising the gated panels.
42
+ */
43
+ const designSecondaryLeftPanelsSetting =
44
+ typeof import.meta !== "undefined"
45
+ ? (import.meta as { env?: Record<string, string> }).env
46
+ ?.VITE_SHOW_DESIGN_SECONDARY_LEFT_PANELS
47
+ : undefined;
48
+ export const SHOW_DESIGN_SECONDARY_LEFT_PANELS =
49
+ designSecondaryLeftPanelsSetting === "1" ||
50
+ designSecondaryLeftPanelsSetting === "true";
51
+ export const SHOW_DESIGN_CODE_LEFT_PANEL = SHOW_DESIGN_SECONDARY_LEFT_PANELS;
52
+
53
+ export function isDesignLeftPanelEnabled(
54
+ value: unknown,
55
+ ): value is DesignLeftPanel {
56
+ if (
57
+ value !== "file" &&
58
+ value !== "agent" &&
59
+ value !== "assets" &&
60
+ value !== "tools" &&
61
+ value !== "tokens" &&
62
+ value !== "import" &&
63
+ value !== "code"
64
+ ) {
65
+ return false;
66
+ }
67
+
68
+ if (value === "code") return SHOW_DESIGN_CODE_LEFT_PANEL;
69
+ if (value === "assets" || value === "tools" || value === "tokens") {
70
+ return SHOW_DESIGN_SECONDARY_LEFT_PANELS;
71
+ }
72
+ return true;
73
+ }
37
74
 
38
75
  /** Zoom percentage applied when entering single-screen (focused) editor mode. */
39
76
  export const FOCUSED_SCREEN_ZOOM = 100;
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: changed
3
+ date: 2026-08-18
4
+ ---
5
+
6
+ Advanced Design editor panels are hidden by default while they are refined.
@@ -22,9 +22,24 @@ const E2E_DATABASE_URL =
22
22
  process.env.E2E_DATABASE_URL ??
23
23
  `file:${path.join(import.meta.dirname, "data", "e2e.db")}`;
24
24
  const BROWSER_CHANNEL = process.env.E2E_BROWSER_CHANNEL;
25
+ const SHOW_SECONDARY_PANELS_IN_E2E =
26
+ process.env.E2E_SHOW_DESIGN_SECONDARY_LEFT_PANELS !== "0";
27
+ const SECONDARY_PANELS_ENV = SHOW_SECONDARY_PANELS_IN_E2E
28
+ ? "VITE_SHOW_DESIGN_SECONDARY_LEFT_PANELS=1 "
29
+ : "VITE_SHOW_DESIGN_SECONDARY_LEFT_PANELS=0 ";
30
+ const ADVANCED_PANEL_SPEC_FILES = [
31
+ /canvas-tools\.spec\.ts$/,
32
+ /code-native-deep-surfaces\.spec\.ts$/,
33
+ /code-native-pr-surfaces\.spec\.ts$/,
34
+ /code-workbench-local-files\.spec\.ts$/,
35
+ ];
25
36
 
26
37
  export default defineConfig({
27
38
  testDir: "./e2e",
39
+ // These suites intentionally exercise panels that are not mounted in the
40
+ // disabled profile. Ignoring them keeps that profile focused on verifying
41
+ // the hidden-panel contract without failing on missing advanced controls.
42
+ testIgnore: SHOW_SECONDARY_PANELS_IN_E2E ? [] : ADVANCED_PANEL_SPEC_FILES,
28
43
  // The editor is heavy (iframe bridge + polling); give generous budgets.
29
44
  timeout: 90_000,
30
45
  expect: { timeout: 15_000 },
@@ -56,9 +71,11 @@ export default defineConfig({
56
71
  // generic DATABASE_URL, but set both to an absolute SQLite URL so a
57
72
  // `.env` Postgres URL or a changed command cwd can never override this
58
73
  // throwaway local db.
59
- command: `APP_NAME=design DESIGN_DATABASE_URL=${JSON.stringify(E2E_DATABASE_URL)} DATABASE_URL=${JSON.stringify(E2E_DATABASE_URL)} PORT=${PORT} corepack pnpm dev`,
74
+ command: `APP_NAME=design ${SECONDARY_PANELS_ENV}DESIGN_DATABASE_URL=${JSON.stringify(E2E_DATABASE_URL)} DATABASE_URL=${JSON.stringify(E2E_DATABASE_URL)} PORT=${PORT} corepack pnpm dev`,
60
75
  url: BASE_URL,
61
- reuseExistingServer: !process.env.CI,
76
+ // The panel flag is compiled into the Vite bundle. Reusing a local
77
+ // server can therefore run this profile with the opposite setting.
78
+ reuseExistingServer: false,
62
79
  // Cold Vite dep-optimization on first boot can exceed two minutes.
63
80
  timeout: 300_000,
64
81
  stdout: "ignore",
@@ -62,6 +62,11 @@ import { ensureThread, warmThreads } from "@/lib/thread-cache";
62
62
  import { cn } from "@/lib/utils";
63
63
 
64
64
  import { EmailListItem } from "./EmailListItem";
65
+ import {
66
+ observeNextPage,
67
+ retryNextPage as runPaginationRetry,
68
+ shouldShowPaginationRetry,
69
+ } from "./infinite-pagination";
65
70
 
66
71
  type EmailsPage = { emails: EmailMessage[]; nextPageToken?: string };
67
72
  type InfiniteEmails = InfiniteData<EmailsPage, string | undefined>;
@@ -83,6 +88,7 @@ interface EmailListProps {
83
88
  hasNextPage?: boolean;
84
89
  fetchNextPage?: () => Promise<unknown>;
85
90
  isFetchingNextPage?: boolean;
91
+ isFetchNextPageError?: boolean;
86
92
  focusedId: string | null;
87
93
  setFocusedId: (id: string | null) => void;
88
94
  selectedIds: Set<string>;
@@ -366,6 +372,7 @@ export function EmailList({
366
372
  hasNextPage: hasNextPageProp,
367
373
  fetchNextPage: fetchNextPageProp,
368
374
  isFetchingNextPage: isFetchingNextPageProp,
375
+ isFetchNextPageError: isFetchNextPageErrorProp,
369
376
  focusedId,
370
377
  setFocusedId,
371
378
  selectedIds,
@@ -397,6 +404,7 @@ export function EmailList({
397
404
  hasNextPage: fetchedEmailsHasNextPage,
398
405
  fetchNextPage: fetchFetchedNextPage,
399
406
  isFetchingNextPage: fetchedEmailsFetchingNextPage,
407
+ isFetchNextPageError: fetchedEmailsFetchNextPageError,
400
408
  } = useEmails(view, searchQuery, labelParam ?? undefined, {
401
409
  enabled: emailsProp === undefined,
402
410
  });
@@ -410,6 +418,13 @@ export function EmailList({
410
418
  const fetchNextPage = fetchNextPageProp ?? fetchFetchedNextPage;
411
419
  const isFetchingNextPage =
412
420
  isFetchingNextPageProp ?? fetchedEmailsFetchingNextPage;
421
+ const isFetchNextPageError =
422
+ isFetchNextPageErrorProp ?? fetchedEmailsFetchNextPageError;
423
+ const paginationRetryInFlightRef = useRef(false);
424
+ const retryNextPage = useCallback(
425
+ () => runPaginationRetry(fetchNextPage, paginationRetryInFlightRef),
426
+ [fetchNextPage],
427
+ );
413
428
  const markRead = useMarkRead();
414
429
  const markThreadRead = useMarkThreadRead();
415
430
  const toggleStar = useToggleStar();
@@ -1127,31 +1142,20 @@ export function EmailList({
1127
1142
  }, [focusedIndex, rowVirtualizer]);
1128
1143
 
1129
1144
  // Infinite scroll — fetch next page when the sentinel enters the viewport.
1130
- // isFetchingNextPage is read via ref (not a dep) because re-observe fires
1131
- // the callback synchronously with the current intersection state; if the
1132
- // sentinel is still visible after a fetch, a reconnecting observer would
1133
- // immediately fire again and we'd loop (visible to the user as "Loading
1134
- // more..." flashing every ~second while tab is idle).
1145
+ // Re-arm the observer when a fetch completes so a still-visible sentinel can
1146
+ // walk through consecutive pages with no client-side filtered matches.
1135
1147
  const sentinelRef = useRef<HTMLDivElement>(null);
1136
- const isFetchingNextPageRef = useRef(isFetchingNextPage);
1137
- isFetchingNextPageRef.current = isFetchingNextPage;
1138
1148
  useEffect(() => {
1139
1149
  const el = sentinelRef.current;
1140
- if (!el || !hasNextPage) return;
1141
- const observer = new IntersectionObserver(
1142
- (entries) => {
1143
- if (entries[0].isIntersecting && !isFetchingNextPageRef.current) {
1144
- void fetchNextPage().catch(() => {
1145
- // React Query owns the visible error state; avoid a global
1146
- // unhandledrejection for transient list-page fetch failures.
1147
- });
1148
- }
1149
- },
1150
- { rootMargin: "200px" },
1151
- );
1152
- observer.observe(el);
1153
- return () => observer.disconnect();
1154
- }, [hasNextPage, fetchNextPage]);
1150
+ if (!el) return;
1151
+ return observeNextPage({
1152
+ element: el,
1153
+ hasNextPage: Boolean(hasNextPage),
1154
+ isFetchingNextPage: Boolean(isFetchingNextPage),
1155
+ isFetchNextPageError: Boolean(isFetchNextPageError),
1156
+ fetchNextPage,
1157
+ });
1158
+ }, [hasNextPage, isFetchingNextPage, isFetchNextPageError, fetchNextPage]);
1155
1159
 
1156
1160
  // Advance selection when an email is snoozed (same logic as archiveFocused)
1157
1161
  useEffect(() => {
@@ -1597,6 +1601,41 @@ export function EmailList({
1597
1601
  return <MailLoadingState containerRef={containerRef} />;
1598
1602
  }
1599
1603
 
1604
+ // Client-sliced inbox tabs can have no matches on the first page even when
1605
+ // later inbox pages contain matching threads. Keep the sentinel mounted so
1606
+ // the infinite query can continue before showing an empty state.
1607
+ if (threads.length === 0 && hasNextPage) {
1608
+ return (
1609
+ <div className="flex h-full flex-col" ref={containerRef}>
1610
+ <div className="flex flex-1 items-center justify-center" />
1611
+ <div
1612
+ ref={sentinelRef}
1613
+ className="flex items-center justify-center py-3"
1614
+ >
1615
+ {isFetchingNextPage && (
1616
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
1617
+ <Spinner className="size-3 text-muted-foreground" />
1618
+ {t("mail.empty.loadingMore")}
1619
+ </div>
1620
+ )}
1621
+ {shouldShowPaginationRetry({
1622
+ hasNextPage: Boolean(hasNextPage),
1623
+ isFetchingNextPage: Boolean(isFetchingNextPage),
1624
+ isFetchNextPageError: Boolean(isFetchNextPageError),
1625
+ }) && (
1626
+ <button
1627
+ type="button"
1628
+ onClick={() => void retryNextPage()}
1629
+ className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground shadow-sm hover:bg-primary/90"
1630
+ >
1631
+ {t("mail.error.tryAgain")}
1632
+ </button>
1633
+ )}
1634
+ </div>
1635
+ </div>
1636
+ );
1637
+ }
1638
+
1600
1639
  // Empty state
1601
1640
  if (threads.length === 0) {
1602
1641
  if (searchQuery) {
@@ -1737,6 +1776,19 @@ export function EmailList({
1737
1776
  {t("mail.empty.loadingMore")}
1738
1777
  </div>
1739
1778
  )}
1779
+ {shouldShowPaginationRetry({
1780
+ hasNextPage: Boolean(hasNextPage),
1781
+ isFetchingNextPage: Boolean(isFetchingNextPage),
1782
+ isFetchNextPageError: Boolean(isFetchNextPageError),
1783
+ }) && (
1784
+ <button
1785
+ type="button"
1786
+ onClick={() => void retryNextPage()}
1787
+ className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground shadow-sm hover:bg-primary/90"
1788
+ >
1789
+ {t("mail.error.tryAgain")}
1790
+ </button>
1791
+ )}
1740
1792
  </div>
1741
1793
  )}
1742
1794
  </div>
@@ -0,0 +1,82 @@
1
+ interface ObserveNextPageOptions {
2
+ element: Element;
3
+ hasNextPage: boolean;
4
+ isFetchingNextPage: boolean;
5
+ isFetchNextPageError: boolean;
6
+ fetchNextPage: () => Promise<unknown>;
7
+ }
8
+
9
+ export interface PaginationRetryState {
10
+ hasNextPage: boolean;
11
+ isFetchingNextPage: boolean;
12
+ isFetchNextPageError: boolean;
13
+ }
14
+
15
+ export function shouldShowPaginationRetry({
16
+ hasNextPage,
17
+ isFetchingNextPage,
18
+ isFetchNextPageError,
19
+ }: PaginationRetryState): boolean {
20
+ return hasNextPage && isFetchNextPageError && !isFetchingNextPage;
21
+ }
22
+
23
+ /**
24
+ * Run an explicit pagination retry without leaking a rejected provider promise
25
+ * to the browser. The shared state closes the synchronous double-click window
26
+ * before React Query has reflected isFetchingNextPage.
27
+ */
28
+ export async function retryNextPage(
29
+ fetchNextPage: () => Promise<unknown>,
30
+ inFlightState: { current: boolean },
31
+ ): Promise<void> {
32
+ if (inFlightState.current) return;
33
+ inFlightState.current = true;
34
+ try {
35
+ await fetchNextPage();
36
+ } catch {
37
+ // coercion-ok: React Query owns the error; catch prevents an unhandled retry rejection.
38
+ // React Query owns the visible error state for the failed page.
39
+ } finally {
40
+ inFlightState.current = false;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Observe a visible infinite-scroll sentinel for one page at a time.
46
+ *
47
+ * The owning effect re-runs when `isFetchingNextPage` changes. That lets the
48
+ * next observer receive the sentinel's already-visible state after a fetch,
49
+ * including when several consecutive pages contain no filtered matches. A
50
+ * failed page is deliberately left unobserved until the user explicitly
51
+ * retries it, so a persistent provider error cannot create a request loop.
52
+ */
53
+ export function observeNextPage({
54
+ element,
55
+ hasNextPage,
56
+ isFetchingNextPage,
57
+ isFetchNextPageError,
58
+ fetchNextPage,
59
+ }: ObserveNextPageOptions): () => void {
60
+ if (!hasNextPage || isFetchingNextPage || isFetchNextPageError)
61
+ return () => {};
62
+
63
+ let fetchInFlight = false;
64
+ const observer = new IntersectionObserver(
65
+ (entries) => {
66
+ if (!entries[0]?.isIntersecting || fetchInFlight) return;
67
+
68
+ fetchInFlight = true;
69
+ void fetchNextPage()
70
+ .catch(() => {
71
+ // React Query owns the visible error state; avoid a global
72
+ // unhandledrejection for transient list-page fetch failures.
73
+ })
74
+ .finally(() => {
75
+ fetchInFlight = false;
76
+ });
77
+ },
78
+ { rootMargin: "200px" },
79
+ );
80
+ observer.observe(element);
81
+ return () => observer.disconnect();
82
+ }
@@ -573,6 +573,7 @@ export function useEmails(
573
573
  hasNextPage: q.hasNextPage,
574
574
  fetchNextPage: q.fetchNextPage,
575
575
  isFetchingNextPage: q.isFetchingNextPage,
576
+ isFetchNextPageError: q.isFetchNextPageError,
576
577
  };
577
578
  }
578
579
 
@@ -31,6 +31,8 @@ import {
31
31
  import { groupIntoThreads, type ThreadSummary } from "@/lib/threads";
32
32
  import { cn } from "@/lib/utils";
33
33
 
34
+ import { shouldShowInboxZero } from "./inbox-zero";
35
+
34
36
  function ContactPanel({
35
37
  emailId,
36
38
  contactEmail,
@@ -315,6 +317,7 @@ export function InboxPage() {
315
317
  hasNextPage,
316
318
  fetchNextPage,
317
319
  isFetchingNextPage,
320
+ isFetchNextPageError,
318
321
  } = useEmails(view, searchQuery, effectiveLabel);
319
322
  const hasEmailData = rawEmails !== undefined;
320
323
  const emailListLoading =
@@ -590,16 +593,17 @@ export function InboxPage() {
590
593
 
591
594
  const isMobile = useIsMobile();
592
595
  const hasThread = !!threadId;
593
- const showsScenicInboxZero =
594
- view === "inbox" && (!activeLabel || activeLabel === "important");
595
- const isInboxZero =
596
- showsScenicInboxZero &&
597
- hasEmailData &&
598
- !emailListLoading &&
599
- !isError &&
600
- !hasThread &&
601
- !searchQuery &&
602
- threads.length === 0;
596
+ const isInboxZero = shouldShowInboxZero({
597
+ view,
598
+ activeLabel,
599
+ hasEmailData,
600
+ isLoading: emailListLoading,
601
+ isError,
602
+ hasThread,
603
+ searchQuery,
604
+ threadCount: threads.length,
605
+ hasNextPage: Boolean(hasNextPage),
606
+ });
603
607
  const [sidebarContactEmail, setSidebarContactEmail] = useState<
604
608
  string | undefined
605
609
  >();
@@ -680,6 +684,7 @@ export function InboxPage() {
680
684
  hasNextPage={hasNextPage}
681
685
  fetchNextPage={fetchNextPage}
682
686
  isFetchingNextPage={isFetchingNextPage}
687
+ isFetchNextPageError={isFetchNextPageError}
683
688
  />
684
689
  )}
685
690
  </div>
@@ -0,0 +1,35 @@
1
+ export interface InboxZeroState {
2
+ view: string;
3
+ activeLabel: string | null | undefined;
4
+ hasEmailData: boolean;
5
+ isLoading: boolean;
6
+ isError: boolean;
7
+ hasThread: boolean;
8
+ searchQuery: string | undefined;
9
+ threadCount: number;
10
+ hasNextPage: boolean;
11
+ }
12
+
13
+ export function shouldShowInboxZero({
14
+ view,
15
+ activeLabel,
16
+ hasEmailData,
17
+ isLoading,
18
+ isError,
19
+ hasThread,
20
+ searchQuery,
21
+ threadCount,
22
+ hasNextPage,
23
+ }: InboxZeroState): boolean {
24
+ return (
25
+ view === "inbox" &&
26
+ (!activeLabel || activeLabel === "important") &&
27
+ hasEmailData &&
28
+ !isLoading &&
29
+ !isError &&
30
+ !hasThread &&
31
+ !searchQuery &&
32
+ threadCount === 0 &&
33
+ !hasNextPage
34
+ );
35
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-18
4
+ ---
5
+
6
+ Pinned labels now keep loading until matching inbox messages are found.
@@ -6,6 +6,7 @@ import { z } from "zod";
6
6
 
7
7
  import { formatSlideHtml } from "../server/lib/slide-content-patch.js";
8
8
  import { summarizeSlideAnimationTargets } from "../server/lib/validate-slide-animations.js";
9
+ import { normalizeOwnerEmail } from "../shared/ownership.js";
9
10
  import { hashSlideContent } from "../shared/slide-fit.js";
10
11
  import "../server/db/index.js"; // ensure registerShareableResource runs
11
12
 
@@ -112,6 +113,7 @@ export default defineAction({
112
113
  const data = JSON.parse(row.data);
113
114
  const slides = data?.slides || [];
114
115
  const ownerEmail = getRequestUserEmail();
116
+ const normalizedOwnerEmail = normalizeOwnerEmail(ownerEmail);
115
117
  const selectedSlideIndex =
116
118
  args.slideId === undefined
117
119
  ? -1
@@ -198,7 +200,9 @@ export default defineAction({
198
200
  id: row.id,
199
201
  title: row.title || data?.title,
200
202
  visibility: row.visibility,
201
- createdByMe: ownerEmail ? row.ownerEmail === ownerEmail : false,
203
+ createdByMe:
204
+ normalizedOwnerEmail !== null &&
205
+ normalizeOwnerEmail(row.ownerEmail) === normalizedOwnerEmail,
202
206
  designSystemId: row.designSystemId ?? null,
203
207
  slideCount: slides.length,
204
208
  slideNumbering: