@agent-native/core 0.81.1 → 0.81.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 (86) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +25 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/frame.ts +55 -0
  5. package/corpus/core/src/mcp/build-server.ts +30 -4
  6. package/corpus/core/src/mcp/embed-app.ts +33 -18
  7. package/corpus/core/src/server/core-routes-plugin.ts +4 -5
  8. package/corpus/core/src/server/create-server.ts +3 -5
  9. package/corpus/core/src/server/google-oauth.ts +4 -0
  10. package/corpus/templates/calendar/actions/create-event.ts +9 -2
  11. package/corpus/templates/calendar/actions/event-action-helpers.ts +8 -2
  12. package/corpus/templates/calendar/app/components/calendar/GoogleConnectBanner.tsx +11 -0
  13. package/corpus/templates/calendar/app/components/layout/Sidebar.tsx +17 -0
  14. package/corpus/templates/calendar/app/hooks/use-google-auth.ts +70 -39
  15. package/corpus/templates/calendar/changelog/2026-06-30-google-calendar-connection-errors-now-stay-in-the-app-instea.md +6 -0
  16. package/corpus/templates/calendar/server/handlers/google-auth.ts +77 -21
  17. package/corpus/templates/calendar/server/lib/google-calendar.ts +19 -8
  18. package/corpus/templates/design/actions/apply-design-token-edit.ts +13 -6
  19. package/corpus/templates/design/actions/import-design-tokens.ts +17 -13
  20. package/corpus/templates/design/actions/preview-design-token-edit.ts +20 -3
  21. package/corpus/templates/design/actions/run-design-audit.ts +9 -2
  22. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +8 -1
  23. package/corpus/templates/design/app/components/design/LayersPanel.tsx +11 -2
  24. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +89 -16
  25. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +21 -14
  26. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +35 -14
  27. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +20 -3
  28. package/corpus/templates/design/app/pages/DesignEditor.tsx +794 -262
  29. package/corpus/templates/design/app/pages/VisualEdit.tsx +1 -1
  30. package/corpus/templates/design/changelog/2026-06-30-design-editor-undo-drag-drop-style-editing-and-visual-edit-s.md +6 -0
  31. package/corpus/templates/design/changelog/2026-06-30-design-studio-canvas-layers-can-be-copied-pasted-and-reparen.md +6 -0
  32. package/corpus/templates/design/e2e/global-setup.ts +3 -1
  33. package/corpus/templates/design/e2e/helpers.ts +4 -1
  34. package/corpus/templates/design/playwright.config.ts +4 -1
  35. package/corpus/templates/design/shared/resolve-tweaks.ts +29 -6
  36. package/corpus/templates/plan/actions/consume-plan-feedback.ts +1 -9
  37. package/corpus/templates/plan/actions/convert-visual-plan-to-prototype.ts +1 -1
  38. package/corpus/templates/plan/actions/create-plan-design.ts +1 -1
  39. package/corpus/templates/plan/actions/create-prototype-plan.ts +1 -1
  40. package/corpus/templates/plan/actions/create-ui-plan.ts +1 -1
  41. package/corpus/templates/plan/actions/create-visual-plan.ts +36 -19
  42. package/corpus/templates/plan/actions/create-visual-questions.ts +1 -1
  43. package/corpus/templates/plan/actions/create-visual-recap.ts +1 -1
  44. package/corpus/templates/plan/actions/delete-plan-comment.ts +1 -9
  45. package/corpus/templates/plan/actions/delete-visual-plan.ts +1 -9
  46. package/corpus/templates/plan/actions/get-plan-feedback.ts +1 -9
  47. package/corpus/templates/plan/actions/get-visual-plan.ts +1 -9
  48. package/corpus/templates/plan/actions/import-visual-plan-source.ts +1 -1
  49. package/corpus/templates/plan/actions/list-visual-plans.ts +1 -9
  50. package/corpus/templates/plan/actions/reply-to-plan-comment.ts +1 -9
  51. package/corpus/templates/plan/actions/resolve-plan-comment.ts +1 -9
  52. package/corpus/templates/plan/actions/update-visual-plan.ts +1 -1
  53. package/corpus/templates/plan/actions/visual-answer.ts +1 -1
  54. package/corpus/templates/plan/app/components/layout/Layout.tsx +15 -0
  55. package/corpus/templates/plan/app/entry.client.tsx +10 -0
  56. package/corpus/templates/plan/app/global.css +28 -0
  57. package/corpus/templates/plan/shared/plan-content.ts +10 -6
  58. package/dist/client/frame.d.ts.map +1 -1
  59. package/dist/client/frame.js +59 -0
  60. package/dist/client/frame.js.map +1 -1
  61. package/dist/collab/awareness.d.ts +2 -2
  62. package/dist/collab/awareness.d.ts.map +1 -1
  63. package/dist/collab/routes.d.ts +1 -1
  64. package/dist/mcp/build-server.d.ts.map +1 -1
  65. package/dist/mcp/build-server.js +31 -4
  66. package/dist/mcp/build-server.js.map +1 -1
  67. package/dist/mcp/embed-app.d.ts.map +1 -1
  68. package/dist/mcp/embed-app.js +33 -18
  69. package/dist/mcp/embed-app.js.map +1 -1
  70. package/dist/notifications/routes.d.ts +3 -3
  71. package/dist/observability/routes.d.ts +3 -3
  72. package/dist/progress/routes.d.ts +1 -1
  73. package/dist/resources/handlers.d.ts +3 -3
  74. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  75. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  76. package/dist/server/core-routes-plugin.js +1 -2
  77. package/dist/server/core-routes-plugin.js.map +1 -1
  78. package/dist/server/create-server.d.ts.map +1 -1
  79. package/dist/server/create-server.js +1 -2
  80. package/dist/server/create-server.js.map +1 -1
  81. package/dist/server/google-oauth.d.ts +2 -0
  82. package/dist/server/google-oauth.d.ts.map +1 -1
  83. package/dist/server/google-oauth.js +3 -0
  84. package/dist/server/google-oauth.js.map +1 -1
  85. package/dist/server/transcribe-voice.d.ts +1 -1
  86. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 2043
31
- - template files: 4801
31
+ - template files: 4804
@@ -1,5 +1,30 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.81.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 3807702: Fix MCP App embed rendering in ChatGPT/Codex hosts:
8
+ - Stop the `openai:set_globals` storm that left embeds stuck blanking by
9
+ guarding the bridge sync on a signature that ignores host `maxHeight`, and
10
+ by not re-blanking once the app frame has launched.
11
+ - Size embeds to their content: the embedded app reports its real
12
+ `scrollHeight` via `agentNative.contentHeight` and the shell sizes the iframe
13
+ to that (plus chrome) instead of the host max, so plans no longer render far
14
+ too tall or too short.
15
+ - Suppress empty embeds: when a tool whose descriptor declares an embed widget
16
+ produces no embeddable content, the result is marked `isError` so the host
17
+ shows the text result without an empty widget box. This also keeps read-only
18
+ and comment-mutation tools from rendering an embed for results that produce
19
+ no plan surface.
20
+ - Bump the embed shell resource version so hosts refetch the updated shell.
21
+
22
+ ## 0.81.2
23
+
24
+ ### Patch Changes
25
+
26
+ - ea1cc47: Keep OAuth state and environment status checks aligned with scoped workspace credentials.
27
+
3
28
  ## 0.81.1
4
29
 
5
30
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.81.1",
3
+ "version": "0.81.3",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -83,6 +83,60 @@ export function isTrustedFrameMessage(event: MessageEvent): boolean {
83
83
  return event.source === window.parent || event.source === window;
84
84
  }
85
85
 
86
+ // ---------------------------------------------------------------------------
87
+ // Content-height reporting (for MCP App embeds)
88
+ // ---------------------------------------------------------------------------
89
+ //
90
+ // The embed shell renders this app in a cross-origin iframe and can't measure
91
+ // its content, so we post the real content height for the shell to size to.
92
+ // Only shrinks when the document flows to its natural height — embed layouts
93
+ // must not force `100vh`/`h-screen` on the root, or scrollHeight just equals
94
+ // the iframe height.
95
+ let _contentHeightReportingStarted = false;
96
+ let _lastReportedContentHeight = 0;
97
+
98
+ function measureContentHeight(): number {
99
+ if (typeof document === "undefined") return 0;
100
+ const doc = document.documentElement;
101
+ const body = document.body;
102
+ return Math.ceil(
103
+ Math.max(doc ? doc.scrollHeight : 0, body ? body.scrollHeight : 0),
104
+ );
105
+ }
106
+
107
+ function reportContentHeight(): void {
108
+ if (typeof window === "undefined" || window.parent === window) return;
109
+ const height = measureContentHeight();
110
+ if (!height || Math.abs(height - _lastReportedContentHeight) < 2) return;
111
+ _lastReportedContentHeight = height;
112
+ sendToFrame("agentNative.contentHeight", { height });
113
+ }
114
+
115
+ function startContentHeightReporting(): void {
116
+ if (_contentHeightReportingStarted || typeof window === "undefined") return;
117
+ if (window.parent === window) return;
118
+ _contentHeightReportingStarted = true;
119
+ let scheduled = false;
120
+ const schedule = () => {
121
+ if (scheduled) return;
122
+ scheduled = true;
123
+ window.requestAnimationFrame(() => {
124
+ scheduled = false;
125
+ reportContentHeight();
126
+ });
127
+ };
128
+ schedule();
129
+ for (const delay of [100, 300, 800, 1500]) window.setTimeout(schedule, delay);
130
+ try {
131
+ const observer = new ResizeObserver(schedule);
132
+ if (document.documentElement) observer.observe(document.documentElement);
133
+ if (document.body) observer.observe(document.body);
134
+ } catch {
135
+ window.addEventListener("resize", schedule, { passive: true });
136
+ }
137
+ window.addEventListener("load", schedule, { passive: true });
138
+ }
139
+
86
140
  // Listen for frame origin message and cache it.
87
141
  // Only accept from the direct parent frame, and only set once.
88
142
  if (typeof window !== "undefined") {
@@ -102,6 +156,7 @@ if (typeof window !== "undefined") {
102
156
  { type: "agentNative.embeddedAppReady" },
103
157
  getFramePostMessageTargetOrigin() ?? window.location.origin,
104
158
  );
159
+ startContentHeightReporting();
105
160
  }
106
161
  });
107
162
  }
@@ -448,6 +448,18 @@ function purgeEmbedStartUrls(value: unknown, depth = 0): unknown {
448
448
  return value;
449
449
  }
450
450
 
451
+ // True when a tool result carries SOME content. An errored/no-plan result comes
452
+ // back empty (`{}` / null) and would render an empty embed box. We gate on "has
453
+ // content", not "has a URL" — valid embeds often carry data but no URL (the
454
+ // shell mints the embed-start itself).
455
+ function mcpResultHasContent(result: unknown): boolean {
456
+ if (result == null) return false;
457
+ if (typeof result === "string") return result.trim().length > 0;
458
+ if (Array.isArray(result)) return result.length > 0;
459
+ if (typeof result === "object") return Object.keys(result).length > 0;
460
+ return Boolean(result);
461
+ }
462
+
451
463
  function mcpAppEmbedOpenLinkMeta(
452
464
  result: unknown,
453
465
  resource: ResolvedMcpAppResource,
@@ -750,7 +762,7 @@ function safeUiSegment(value: string | undefined, fallback: string): string {
750
762
 
751
763
  // ChatGPT and Claude cache MCP App resource HTML by `ui://` URI. Bump this
752
764
  // when the shared shell changes in a way that must invalidate host caches.
753
- const MCP_APP_RESOURCE_SHELL_VERSION = "shell-v54";
765
+ const MCP_APP_RESOURCE_SHELL_VERSION = "shell-v64";
754
766
 
755
767
  function legacyDefaultMcpAppUri(config: MCPConfig, actionName: string): string {
756
768
  const app = safeUiSegment(config.appId ?? config.name, "agent-native");
@@ -1546,12 +1558,24 @@ export async function createMCPServerForRequest(
1546
1558
  // branch below degrades to the plain deep-link artifacts the tool would
1547
1559
  // otherwise return — no `openai/outputTemplate`, no minted embed-start,
1548
1560
  // no embed structuredContent — so the host shows a link, not an iframe.
1549
- const mcpAppResource = requestMeta?.inlineMcpApps
1561
+ const mcpAppResourceCandidate = requestMeta?.inlineMcpApps
1550
1562
  ? await resolveMcpAppResourceSafely(config, name, entry, requestMeta)
1551
1563
  : null;
1552
- const rawResultForClient = mcpAppResource
1564
+ const rawResultForClient = mcpAppResourceCandidate
1553
1565
  ? await withServerMintedMcpAppEmbedStart(rawResult, requestMeta)
1554
1566
  : rawResult;
1567
+ // Only attach the embed widget for a non-error result that has content.
1568
+ const embedHasContent = mcpResultHasContent(rawResultForClient);
1569
+ const mcpAppResource =
1570
+ mcpAppResourceCandidate && !mcpResultIsError && embedHasContent
1571
+ ? mcpAppResourceCandidate
1572
+ : null;
1573
+ // `openai/outputTemplate` is declared at the tool level, so the host
1574
+ // renders a widget for every call regardless of result _meta. The only
1575
+ // per-result signal it honors is `isError` (shows error text, no widget),
1576
+ // so treat an embed tool that produced nothing as an error.
1577
+ const embedProducedNothing =
1578
+ !!mcpAppResourceCandidate && !mcpResultIsError && !embedHasContent;
1555
1579
  const { block, _meta } = buildLinkArtifacts(
1556
1580
  entry,
1557
1581
  (args as Record<string, any>) ?? {},
@@ -1590,7 +1614,9 @@ export async function createMCPServerForRequest(
1590
1614
  if (block) content.push(block);
1591
1615
  return {
1592
1616
  content,
1593
- ...(mcpResultIsError ? { isError: true } : {}),
1617
+ ...(mcpResultIsError || embedProducedNothing
1618
+ ? { isError: true }
1619
+ : {}),
1594
1620
  ...(structuredContent ? { structuredContent } : {}),
1595
1621
  ...(Object.keys(responseMeta).length > 0
1596
1622
  ? { _meta: responseMeta }
@@ -138,6 +138,7 @@ export function embedApp(
138
138
  let appFrameLoadTimer = null;
139
139
  let lastFrameSrc = "";
140
140
  let embedSessionRefreshAttempts = 0;
141
+ let reportedContentHeight = 0;
141
142
 
142
143
  function esc(value) {
143
144
  return String(value ?? "")
@@ -174,10 +175,18 @@ export function embedApp(
174
175
  function visibleIntrinsicHeight() {
175
176
  const context = hostState().context || {};
176
177
  const hostMaxHeight = contextMaxHeight(context);
178
+ // Size to the app-reported content height (the shell can't measure the
179
+ // cross-origin frame). Don't clamp to the host maxHeight — on Codex it's a
180
+ // small inline hint (~360) that clips the plan; applyIntrinsicHeight still
181
+ // caps at defaultIntrinsicHeight.
182
+ if (reportedContentHeight > 0) {
183
+ return Math.floor(reportedContentHeight + chromeHeight);
184
+ }
177
185
  if (hostMaxHeight) return Math.floor(hostMaxHeight);
178
- const viewportHeight = finiteNumber(window.visualViewport && window.visualViewport.height) ||
179
- finiteNumber(window.innerHeight);
180
- return Math.floor(viewportHeight || defaultIntrinsicHeight);
186
+ // No host maxHeight and no content height yet: use the CONFIGURED height,
187
+ // never window.innerHeight. Echoing the iframe's own size pins us to the
188
+ // current frame height (circular) and locks the embed at the cap forever.
189
+ return Math.floor(defaultIntrinsicHeight);
181
190
  }
182
191
 
183
192
  function applyIntrinsicHeight(nextHeight) {
@@ -1356,7 +1365,8 @@ export function embedApp(
1356
1365
  }
1357
1366
 
1358
1367
  function notifyHostHeight() {
1359
- const height = applyIntrinsicHeight(visibleIntrinsicHeight());
1368
+ const intrinsic = visibleIntrinsicHeight();
1369
+ const height = applyIntrinsicHeight(intrinsic);
1360
1370
  if (!openAiBridge || typeof openAiBridge.notifyIntrinsicHeight !== "function") {
1361
1371
  if (app && typeof app.sendSizeChanged === "function") {
1362
1372
  try {
@@ -1487,6 +1497,14 @@ export function embedApp(
1487
1497
  notifyOuterMcpAppReady();
1488
1498
  return;
1489
1499
  }
1500
+ if (event.data.type === "agentNative.contentHeight") {
1501
+ const next = finiteNumber(data && data.height);
1502
+ if (next && Math.abs(next - reportedContentHeight) >= 1) {
1503
+ reportedContentHeight = next;
1504
+ notifyHostHeightSoon();
1505
+ }
1506
+ return;
1507
+ }
1490
1508
  if (event.data.type === "agentNative.embedSessionExpired") {
1491
1509
  refreshExpiredEmbedSession();
1492
1510
  return;
@@ -1538,12 +1556,9 @@ export function embedApp(
1538
1556
  const selfNavigate = shouldSelfNavigateToApp();
1539
1557
  if (startedFor === launchUrl) return;
1540
1558
  startedFor = launchUrl;
1541
- // Only show the loading message when no frame is mounted yet. ChatGPT
1542
- // fires openai:set_globals (and thus re-launch) constantly; calling
1543
- // setMessage() unconditionally here wiped the just-mounted iframe out of
1544
- // the stage on every redundant launch, so the app rendered and was
1545
- // instantly blanked back to "Loading app" forever. renderFrame()/
1546
- // transplant replace the stage themselves when they actually (re)mount.
1559
+ // Only show "Loading app" when no frame is mounted. set_globals
1560
+ // re-launches constantly, so calling it every time would wipe the
1561
+ // just-mounted iframe; renderFrame/transplant own the stage on (re)mount.
1547
1562
  if (!appFrame) {
1548
1563
  setMessage("Loading app");
1549
1564
  }
@@ -1671,14 +1686,11 @@ export function embedApp(
1671
1686
  toolResultData = objectValue(data);
1672
1687
  openUrl = openLinkFrom(params, data);
1673
1688
  openStartUrl = embedStartUrlFrom(params, data);
1674
- // ChatGPT/Codex fire openai:set_globals extremely often, and the listener
1675
- // that calls this re-syncs on each one. Because this function calls
1676
- // notifyHostHeight()/sendHostContext() (which the host reflects back as a
1677
- // fresh set_globals), an unguarded sync becomes an infinite feedback storm
1678
- // that starves the host until it shows its sad-face placeholder. Only do
1679
- // the host round-trips + (re)launch when something we care about changed.
1680
- // Deliberately EXCLUDE bridge.maxHeight: the host mutates it on every
1681
- // set_globals as it echoes our height back, which would defeat the guard.
1689
+ // set_globals fires constantly, and this sync calls notifyHostHeight/
1690
+ // sendHostContext which the host echoes back as another set_globals an
1691
+ // infinite storm. Only do the host round-trips + (re)launch when something
1692
+ // we care about changed. Exclude maxHeight: the host mutates it every
1693
+ // event, which would defeat the guard.
1682
1694
  let signature;
1683
1695
  try {
1684
1696
  signature = JSON.stringify([
@@ -1704,6 +1716,9 @@ export function embedApp(
1704
1716
  if (openUrl || openStartUrl) {
1705
1717
  void launchEmbed();
1706
1718
  } else if (!appFrame) {
1719
+ // No open URL yet — a content-less result no longer reaches the host as
1720
+ // a widget (build-server marks it isError), so this is the transient
1721
+ // "waiting for the embed to resolve" state, not an empty box.
1707
1722
  setMessage("Waiting for app result");
1708
1723
  }
1709
1724
  return true;
@@ -2316,11 +2316,10 @@ export function createCoreRoutesPlugin(
2316
2316
  }
2317
2317
  return Promise.all(
2318
2318
  envKeys.map(async (cfg) => {
2319
- const configured =
2320
- Boolean(process.env[cfg.key]) ||
2321
- (await runWithRequestContext({ userEmail, orgId }, () =>
2322
- resolveSecret(cfg.key).then(Boolean),
2323
- ));
2319
+ const configured = await runWithRequestContext(
2320
+ { userEmail, orgId },
2321
+ () => resolveSecret(cfg.key).then(Boolean),
2322
+ );
2324
2323
  return {
2325
2324
  key: cfg.key,
2326
2325
  label: cfg.label,
@@ -221,11 +221,9 @@ export function createServer(
221
221
  key: cfg.key,
222
222
  label: cfg.label,
223
223
  required: cfg.required ?? false,
224
- configured:
225
- Boolean(process.env[cfg.key]) ||
226
- (await runWithRequestContext({ userEmail, orgId }, () =>
227
- resolveSecret(cfg.key).then(Boolean),
228
- )),
224
+ configured: await runWithRequestContext({ userEmail, orgId }, () =>
225
+ resolveSecret(cfg.key).then(Boolean),
226
+ ),
229
227
  ...(cfg.helpText ? { helpText: cfg.helpText } : {}),
230
228
  })),
231
229
  );
@@ -442,6 +442,7 @@ export function resolveOAuthRedirectUri(
442
442
  export interface OAuthStatePayload {
443
443
  redirectUri: string;
444
444
  owner?: string;
445
+ orgId?: string;
445
446
  desktop?: boolean;
446
447
  addAccount?: boolean;
447
448
  app?: string;
@@ -512,6 +513,7 @@ function getStateSigningKey(): string {
512
513
  export interface EncodeOAuthStateOptions {
513
514
  redirectUri: string;
514
515
  owner?: string;
516
+ orgId?: string;
515
517
  desktop?: boolean;
516
518
  addAccount?: boolean;
517
519
  app?: string;
@@ -585,6 +587,7 @@ export function encodeOAuthState(
585
587
  r: opts.redirectUri,
586
588
  };
587
589
  if (opts.owner) payload.o = opts.owner;
590
+ if (opts.orgId) payload.g = opts.orgId;
588
591
  if (opts.desktop) payload.d = true;
589
592
  if (opts.addAccount) payload.a = true;
590
593
  if (opts.app) payload.app = opts.app;
@@ -631,6 +634,7 @@ export function decodeOAuthState(
631
634
  return {
632
635
  redirectUri: parsed.r || fallbackUri,
633
636
  owner: parsed.o || undefined,
637
+ orgId: typeof parsed.g === "string" ? parsed.g : undefined,
634
638
  desktop: !!parsed.d,
635
639
  addAccount: !!parsed.a,
636
640
  app: typeof parsed.app === "string" ? parsed.app : undefined,
@@ -1,6 +1,10 @@
1
1
  import { defineAction } from "@agent-native/core";
2
2
  import { emit } from "@agent-native/core/event-bus";
3
- import { getRequestUserEmail, buildDeepLink } from "@agent-native/core/server";
3
+ import {
4
+ buildDeepLink,
5
+ getRequestOrgId,
6
+ getRequestUserEmail,
7
+ } from "@agent-native/core/server";
4
8
  import { z } from "zod";
5
9
 
6
10
  import { prepareZoomMeetingPatch } from "../server/lib/event-video-conferencing.js";
@@ -151,7 +155,10 @@ export default defineAction({
151
155
  // Resolve account email
152
156
  let acctEmail = email;
153
157
  if (args.accountEmail && args.accountEmail !== email) {
154
- const status = await googleCalendar.getAuthStatus(email);
158
+ const status = await googleCalendar.getAuthStatus(
159
+ email,
160
+ getRequestOrgId(),
161
+ );
155
162
  const isOwned = status.accounts.some(
156
163
  (a) => a.email === args.accountEmail,
157
164
  );
@@ -1,4 +1,7 @@
1
- import { getRequestUserEmail } from "@agent-native/core/server";
1
+ import {
2
+ getRequestOrgId,
3
+ getRequestUserEmail,
4
+ } from "@agent-native/core/server";
2
5
  import { z } from "zod";
3
6
 
4
7
  import * as googleCalendar from "../server/lib/google-calendar.js";
@@ -71,7 +74,10 @@ export async function resolveOwnedAccountEmail(
71
74
  requestedAccountEmail: string | undefined,
72
75
  ownerEmail: string,
73
76
  ): Promise<string> {
74
- const status = await googleCalendar.getAuthStatus(ownerEmail);
77
+ const status = await googleCalendar.getAuthStatus(
78
+ ownerEmail,
79
+ getRequestOrgId(),
80
+ );
75
81
  if (!requestedAccountEmail) {
76
82
  if (status.accounts.length === 1) {
77
83
  return status.accounts[0].email;
@@ -189,6 +189,17 @@ export function GoogleConnectBanner({
189
189
  }
190
190
  }, [authUrl.error, fetchStatus]);
191
191
 
192
+ useEffect(() => {
193
+ if (
194
+ desktopAuthIssue?.code !== "missing_credentials" &&
195
+ desktopAuthIssue?.error !== "missing_credentials"
196
+ ) {
197
+ return;
198
+ }
199
+ setShowWizard(true);
200
+ fetchStatus();
201
+ }, [desktopAuthIssue, fetchStatus]);
202
+
192
203
  const allConfigured =
193
204
  envStatus.length > 0 && envStatus.every((k) => k.configured);
194
205
 
@@ -44,6 +44,7 @@ import {
44
44
  } from "date-fns";
45
45
  import { useState, useEffect, useMemo } from "react";
46
46
  import { Link, useLocation, useNavigate } from "react-router";
47
+ import { toast } from "sonner";
47
48
 
48
49
  import { ThemeToggle } from "@/components/ThemeToggle";
49
50
  import { Button } from "@/components/ui/button";
@@ -322,6 +323,8 @@ function GoogleConnectSidebarButton() {
322
323
  isGoogleDesktopAuthPending,
323
324
  startDesktopGoogleAuth,
324
325
  } = useGoogleDesktopAuth({
326
+ onError: (issue) =>
327
+ toast.error(issue.message || issue.error || t("settings.googleFailed")),
325
328
  onSuccess: () => window.location.reload(),
326
329
  });
327
330
 
@@ -331,6 +334,12 @@ function GoogleConnectSidebarButton() {
331
334
  window.open(authUrl.data.url, "_blank");
332
335
  }, [wantAuthUrl, authUrl.data]);
333
336
 
337
+ useEffect(() => {
338
+ if (!authUrl.error) return;
339
+ toast.error(authUrl.error.message);
340
+ setWantAuthUrl(false);
341
+ }, [authUrl.error]);
342
+
334
343
  function handleConnect() {
335
344
  if (isDesktopGoogleAuth) {
336
345
  startDesktopGoogleAuth({ previousAccountCount: 0 });
@@ -435,6 +444,8 @@ function GoogleAccountsSection({
435
444
  isGoogleDesktopAuthPending,
436
445
  startDesktopGoogleAuth,
437
446
  } = useGoogleDesktopAuth({
447
+ onError: (issue) =>
448
+ toast.error(issue.message || issue.error || t("settings.googleFailed")),
438
449
  onSuccess: () => window.location.reload(),
439
450
  });
440
451
 
@@ -444,6 +455,12 @@ function GoogleAccountsSection({
444
455
  setWantAddAccount(false);
445
456
  }, [wantAddAccount, addAccountUrl.data]);
446
457
 
458
+ useEffect(() => {
459
+ if (!addAccountUrl.error) return;
460
+ toast.error(addAccountUrl.error.message);
461
+ setWantAddAccount(false);
462
+ }, [addAccountUrl.error]);
463
+
447
464
  function handleAddAccount() {
448
465
  if (isDesktopGoogleAuth) {
449
466
  startDesktopGoogleAuth({
@@ -45,6 +45,23 @@ function bodyError(
45
45
  `${fallback} (HTTP ${res.status})`;
46
46
  const error = new Error(message);
47
47
  (error as any).status = res.status;
48
+ if (body && typeof body === "object") {
49
+ const record = body as Record<string, unknown>;
50
+ for (const key of [
51
+ "error",
52
+ "code",
53
+ "accountId",
54
+ "existingOwner",
55
+ "attemptedOwner",
56
+ ]) {
57
+ if (typeof record[key] === "string") {
58
+ (error as any)[key] = record[key];
59
+ }
60
+ }
61
+ if (!(error as any).code && typeof record.error === "string") {
62
+ (error as any).code = record.error;
63
+ }
64
+ }
48
65
  return error;
49
66
  }
50
67
 
@@ -204,6 +221,33 @@ export function useGoogleDesktopAuth(options: DesktopAuthOptions = {}) {
204
221
  setIsPending(false);
205
222
  onError?.(issue);
206
223
  };
224
+ const authStartIssue = (err: unknown): DesktopAuthIssue => {
225
+ const source = err as Partial<DesktopAuthIssue> | undefined;
226
+ const message =
227
+ err instanceof Error
228
+ ? err.message
229
+ : "Could not start Google sign-in.";
230
+ return {
231
+ code:
232
+ typeof source?.code === "string"
233
+ ? source.code
234
+ : "desktop_auth_start_failed",
235
+ error: typeof source?.error === "string" ? source.error : undefined,
236
+ message,
237
+ accountId:
238
+ typeof source?.accountId === "string"
239
+ ? source.accountId
240
+ : undefined,
241
+ existingOwner:
242
+ typeof source?.existingOwner === "string"
243
+ ? source.existingOwner
244
+ : undefined,
245
+ attemptedOwner:
246
+ typeof source?.attemptedOwner === "string"
247
+ ? source.attemptedOwner
248
+ : undefined,
249
+ };
250
+ };
207
251
  const openAuthUrl = (url: string) => {
208
252
  if (popup && !popup.closed) {
209
253
  popup.location.href = url;
@@ -220,50 +264,37 @@ export function useGoogleDesktopAuth(options: DesktopAuthOptions = {}) {
220
264
  await onSuccess?.(result);
221
265
  };
222
266
 
223
- if (startOptions.addAccount) {
224
- popup = window.open("about:blank", "_blank");
225
- void (async () => {
226
- try {
227
- const { url } = await fetchJson<{ url: string }>(
228
- agentNativePath(
229
- `/_agent-native/google/add-account/auth-url?${params.toString()}`,
230
- ),
231
- { credentials: "include" },
232
- );
233
- if (!openAuthUrl(url)) {
234
- reportError({
235
- code: "popup_blocked",
236
- message:
237
- "Calendar could not open Google sign-in. Allow popups and try again.",
238
- });
239
- }
240
- } catch (err) {
241
- popup?.close();
267
+ popup = window.open("about:blank", "_blank");
268
+ if (!popup) {
269
+ reportError({
270
+ code: "popup_blocked",
271
+ message:
272
+ "Calendar could not open Google sign-in. Allow popups and try again.",
273
+ });
274
+ return true;
275
+ }
276
+
277
+ void (async () => {
278
+ try {
279
+ const path = startOptions.addAccount
280
+ ? "/_agent-native/google/add-account/auth-url"
281
+ : "/_agent-native/google/auth-url";
282
+ const { url } = await fetchJson<{ url: string }>(
283
+ agentNativePath(`${path}?${params.toString()}`),
284
+ { credentials: "include" },
285
+ );
286
+ if (!openAuthUrl(url)) {
242
287
  reportError({
243
- code: "desktop_auth_start_failed",
288
+ code: "popup_blocked",
244
289
  message:
245
- err instanceof Error
246
- ? err.message
247
- : "Could not start Google sign-in.",
290
+ "Calendar could not open Google sign-in. Allow popups and try again.",
248
291
  });
249
292
  }
250
- })();
251
- } else {
252
- params.set("redirect", "1");
253
- const opened = openAuthUrl(
254
- `${window.location.origin}${agentNativePath(
255
- "/_agent-native/google/auth-url",
256
- )}?${params.toString()}`,
257
- );
258
- if (!opened) {
259
- reportError({
260
- code: "popup_blocked",
261
- message:
262
- "Calendar could not open Google sign-in. Allow popups and try again.",
263
- });
264
- return true;
293
+ } catch (err) {
294
+ popup?.close();
295
+ reportError(authStartIssue(err));
265
296
  }
266
- }
297
+ })();
267
298
 
268
299
  pollRef.current = setInterval(async () => {
269
300
  try {
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-30
4
+ ---
5
+
6
+ Google Calendar connection errors now stay in the app instead of opening a blank sign-in window.