@agent-native/core 0.161.23 → 0.162.0

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 (113) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/real-time-sync/SKILL.md +1 -1
  3. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +18 -2
  4. package/corpus/templates/assets/.agents/skills/real-time-sync/SKILL.md +1 -1
  5. package/corpus/templates/brain/.agents/skills/real-time-sync/SKILL.md +1 -1
  6. package/corpus/templates/calendar/.agents/skills/real-time-sync/SKILL.md +1 -1
  7. package/corpus/templates/calendar/server/handlers/google-auth.ts +1 -8
  8. package/corpus/templates/chat/.agents/skills/real-time-sync/SKILL.md +1 -1
  9. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +88 -35
  10. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +36 -1
  11. package/corpus/templates/clips/app/i18n/en-US.ts +4 -3
  12. package/corpus/templates/clips/desktop/src/app.tsx +30 -40
  13. package/corpus/templates/clips/desktop/src/components/MicOffConfirmation.tsx +47 -0
  14. package/corpus/templates/clips/desktop/src/styles.css +110 -0
  15. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +114 -7
  16. package/corpus/templates/clips/shared/recording-capture.ts +18 -13
  17. package/corpus/templates/content/.agents/skills/real-time-sync/SKILL.md +1 -1
  18. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +3 -5
  19. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +0 -3
  20. package/corpus/templates/content/app/components/editor/NotionConflictBanner.tsx +0 -4
  21. package/corpus/templates/content/app/components/editor/NotionSyncBar.tsx +4 -1
  22. package/corpus/templates/content/app/hooks/use-notion.ts +17 -15
  23. package/corpus/templates/crm/.agents/skills/real-time-sync/SKILL.md +1 -1
  24. package/corpus/templates/design/.agents/skills/real-time-sync/SKILL.md +1 -1
  25. package/corpus/templates/dispatch/.agents/skills/real-time-sync/SKILL.md +1 -1
  26. package/corpus/templates/factory/.agents/skills/real-time-sync/SKILL.md +1 -1
  27. package/corpus/templates/forms/.agents/skills/real-time-sync/SKILL.md +1 -1
  28. package/corpus/templates/macros/.agents/skills/real-time-sync/SKILL.md +1 -1
  29. package/corpus/templates/mail/.agents/skills/real-time-sync/SKILL.md +1 -1
  30. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +18 -3
  31. package/corpus/templates/mail/server/handlers/google-auth.ts +1 -8
  32. package/corpus/templates/plan/.agents/skills/real-time-sync/SKILL.md +1 -1
  33. package/corpus/templates/slides/.agents/skills/create-deck/SKILL.md +4 -0
  34. package/corpus/templates/slides/.agents/skills/real-time-sync/SKILL.md +1 -1
  35. package/corpus/templates/slides/actions/_await-fit-check.ts +30 -5
  36. package/corpus/templates/slides/actions/add-slide.ts +6 -2
  37. package/corpus/templates/slides/actions/create-deck.ts +2 -1
  38. package/corpus/templates/slides/app/lib/create-deck-generation.ts +1 -0
  39. package/corpus/templates/slides/app/pages/DeckEditor.tsx +34 -1
  40. package/corpus/templates/slides/app/routes/p.$id.tsx +3 -1
  41. package/corpus/templates/slides/changelog/2026-08-18-generated-slides-preserve-speaker-notes.md +6 -0
  42. package/corpus/templates/slides/server/handlers/assets.ts +23 -12
  43. package/corpus/templates/slides/server/handlers/design-system-decode-job-status.ts +15 -6
  44. package/corpus/templates/slides/server/handlers/design-system-upload-start.ts +15 -6
  45. package/corpus/templates/slides/server/handlers/index-design-system-sources.ts +34 -19
  46. package/corpus/templates/slides/server/handlers/share.ts +1 -1
  47. package/corpus/templates/slides/server/plugins/agent-chat.ts +1 -1
  48. package/corpus/templates/slides/server/routes/api/deck-agent-context.json.get.ts +1 -1
  49. package/corpus/templates/slides/server/routes/api/exports/[filename].get.ts +9 -3
  50. package/corpus/templates/slides/server/routes/api/exports/google-slides.post.ts +12 -5
  51. package/corpus/templates/slides/server/routes/api/exports/html.post.ts +9 -7
  52. package/corpus/templates/slides/server/routes/api/exports/pptx.post.ts +9 -7
  53. package/corpus/templates/slides/shared/api.ts +7 -1
  54. package/corpus/templates/tasks/.agents/skills/real-time-sync/SKILL.md +1 -1
  55. package/dist/agent/engine/translate-anthropic.js +27 -2
  56. package/dist/agent/production-agent.d.ts +2 -2
  57. package/dist/agent/production-agent.js +16 -8
  58. package/dist/agent/thread-data-builder.d.ts +1 -0
  59. package/dist/agent/thread-data-builder.js +6 -4
  60. package/dist/agent/tool-approval-store.d.ts +10 -1
  61. package/dist/agent/tool-approval-store.js +19 -2
  62. package/dist/agent/types.d.ts +9 -0
  63. package/dist/client/AssistantChat.js +14 -6
  64. package/dist/client/EnvironmentBadge.d.ts +15 -0
  65. package/dist/client/EnvironmentBadge.js +79 -0
  66. package/dist/client/MultiTabAssistantChat.js +73 -17
  67. package/dist/client/agent-chat-adapter.js +11 -1
  68. package/dist/client/api-path.js +14 -1
  69. package/dist/client/app-providers.js +2 -1
  70. package/dist/client/chat/tool-call-display.d.ts +12 -2
  71. package/dist/client/chat/tool-call-display.js +17 -7
  72. package/dist/client/chat/widgets/WorkspaceFileWidget.js +1 -1
  73. package/dist/client/chat/widgets/builtin-tool-renderers.d.ts +6 -0
  74. package/dist/client/chat/widgets/builtin-tool-renderers.js +19 -0
  75. package/dist/client/onboarding/FirstRunOnboarding.js +46 -32
  76. package/dist/client/onboarding/use-onboarding.d.ts +8 -1
  77. package/dist/client/onboarding/use-onboarding.js +22 -7
  78. package/dist/client/session-replay.js +58 -15
  79. package/dist/client/settings/SettingsTabsPage.js +19 -6
  80. package/dist/client/sse-event-processor.d.ts +7 -0
  81. package/dist/client/sse-event-processor.js +4 -1
  82. package/dist/client/ui/index.d.ts +1 -0
  83. package/dist/client/ui/index.js +1 -0
  84. package/dist/client/use-action.js +26 -0
  85. package/dist/collab/awareness.d.ts +2 -2
  86. package/dist/collab/struct-routes.d.ts +1 -1
  87. package/dist/config.d.ts +15 -0
  88. package/dist/config.js +53 -0
  89. package/dist/extensions/fetch-tool.js +8 -2
  90. package/dist/mcp/embed-app.js +1 -1
  91. package/dist/notifications/routes.d.ts +1 -1
  92. package/dist/provider-api/actions/custom-provider-registration.d.ts +13 -13
  93. package/dist/provider-api/actions/provider-api.d.ts +11 -11
  94. package/dist/provider-api/index.d.ts +10 -0
  95. package/dist/provider-api/index.js +45 -9
  96. package/dist/server/auth.js +10 -3
  97. package/dist/server/poll.js +7 -3
  98. package/dist/server/sentry.d.ts +1 -1
  99. package/dist/templates/chat/.agents/skills/real-time-sync/SKILL.md +1 -1
  100. package/dist/templates/default/.agents/skills/real-time-sync/SKILL.md +1 -1
  101. package/dist/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +1 -1
  102. package/dist/vite/client.d.ts +5 -0
  103. package/dist/vite/client.js +64 -2
  104. package/dist/workspace-files/actions.d.ts +1 -8
  105. package/dist/workspace-files/actions.js +5 -21
  106. package/dist/workspace-files/index.d.ts +1 -1
  107. package/dist/workspace-files/index.js +1 -1
  108. package/dist/workspace-files/store.d.ts +18 -0
  109. package/dist/workspace-files/store.js +24 -3
  110. package/package.json +3 -3
  111. package/src/templates/chat/.agents/skills/real-time-sync/SKILL.md +1 -1
  112. package/src/templates/default/.agents/skills/real-time-sync/SKILL.md +1 -1
  113. package/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +1 -1
@@ -261,17 +261,42 @@ export function backfillEngineMessagesToolResults(messages) {
261
261
  // ---------------------------------------------------------------------------
262
262
  // EngineMessage → Anthropic.MessageParam
263
263
  // ---------------------------------------------------------------------------
264
+ /**
265
+ * A thinking block replays only with the signature Anthropic issued for it, or
266
+ * (redacted) with its encrypted payload. An empty signature is not a fallback:
267
+ * the native API rejects the whole request, so a turn that streamed fine dies
268
+ * with a provider error pointing nowhere near the cause. Drop the unsendable
269
+ * block and say so instead of coercing it into a guaranteed 400.
270
+ *
271
+ * Not applied to the Builder gateway: its tolerance for an unsigned thinking
272
+ * block is unverified, so that path keeps its existing behavior rather than
273
+ * trading a working request for an untested one.
274
+ */
275
+ function replayableAnthropicPart(part) {
276
+ if (part.type !== "thinking")
277
+ return true;
278
+ if (part.redactedData || part.signature)
279
+ return true;
280
+ console.warn("[anthropic-engine] dropping a thinking block with no signature; it cannot be replayed");
281
+ return false;
282
+ }
264
283
  export function engineMessageToAnthropic(msg, opts) {
265
284
  const builderGateway = opts?.builderGateway === true;
285
+ const content = builderGateway
286
+ ? msg.content
287
+ : msg.content.filter(replayableAnthropicPart);
266
288
  return {
267
289
  role: msg.role,
268
- content: msg.content.map((p) => enginePartToAnthropic(p, builderGateway)),
290
+ content: content.map((p) => enginePartToAnthropic(p, builderGateway)),
269
291
  };
270
292
  }
271
293
  /** Messages for the Anthropic HTTP API (strict schema — no extra tool_result fields). */
272
294
  export function engineMessagesToAnthropic(messages) {
273
295
  const normalized = backfillEngineMessagesToolResults(messages);
274
- return normalized.map((m) => engineMessageToAnthropic(m));
296
+ return normalized.flatMap((m) => {
297
+ const translated = engineMessageToAnthropic(m);
298
+ return translated.content.length > 0 ? [translated] : [];
299
+ });
275
300
  }
276
301
  /**
277
302
  * Messages for the Builder LLM gateway (Gemini-backed). Same Anthropic-shaped
@@ -700,7 +700,7 @@ export interface ExecuteAgentToolCallOptions {
700
700
  threadId?: string;
701
701
  turnId?: string;
702
702
  approvedToolCalls?: string[];
703
- onApprovalRequired?: (binding: AgentApprovalBinding) => Promise<void>;
703
+ onApprovalRequired?: (binding: AgentApprovalBinding) => Promise<string | void>;
704
704
  consumeApproval?: (binding: AgentApprovalBinding) => Promise<boolean>;
705
705
  send?: (event: AgentChatEvent) => void;
706
706
  }
@@ -877,7 +877,7 @@ export declare function runAgentLoop(opts: {
877
877
  * so client-supplied history is never the authorization source; direct loop
878
878
  * callers may omit them when they own the surrounding approval boundary.
879
879
  */
880
- onApprovalRequired?: (binding: AgentApprovalBinding) => Promise<void>;
880
+ onApprovalRequired?: (binding: AgentApprovalBinding) => Promise<string | void>;
881
881
  consumeApproval?: (binding: AgentApprovalBinding) => Promise<boolean>;
882
882
  /**
883
883
  * In-loop processor seam (see `processors.ts`). Each processor can observe
@@ -4331,7 +4331,14 @@ export async function runAgentLoop(opts) {
4331
4331
  mustApprove = true;
4332
4332
  }
4333
4333
  if (mustApprove) {
4334
- await opts.onApprovalRequired?.(approvalBinding);
4334
+ // `askId` identifies THIS gate hit, distinct from any earlier ask
4335
+ // for the same approvalKey/toolCallId. A failed resume (expired
4336
+ // grant, turn-id mismatch) re-enters this branch and mints a new
4337
+ // askId, which is how the client tells "an already-approved card
4338
+ // re-rendered" apart from "a fresh ask after the grant didn't land"
4339
+ // — without it, a stale client-side "approved" mark permanently
4340
+ // hides Approve/Deny with no way to retry.
4341
+ const askId = (await opts.onApprovalRequired?.(approvalBinding)) || undefined;
4335
4342
  send({
4336
4343
  type: "tool_start",
4337
4344
  id: toolCall.id,
@@ -4343,6 +4350,7 @@ export async function runAgentLoop(opts) {
4343
4350
  tool: toolCall.name,
4344
4351
  input: toolCall.input,
4345
4352
  approvalKey,
4353
+ ...(askId ? { askId } : {}),
4346
4354
  ...(toolCall.id ? { toolCallId: toolCall.id } : {}),
4347
4355
  });
4348
4356
  // Audit the blocked attempt: the action did NOT run, but "the agent
@@ -7194,7 +7202,7 @@ export function createProductionAgentHandler(options) {
7194
7202
  };
7195
7203
  const approvalHooks = {
7196
7204
  onApprovalRequired: async (binding) => {
7197
- await createAgentToolApproval(approvalStoreBinding(binding));
7205
+ return createAgentToolApproval(approvalStoreBinding(binding));
7198
7206
  },
7199
7207
  consumeApproval: async (binding) => {
7200
7208
  if (!ownerEmail)
@@ -7234,12 +7242,12 @@ export function createProductionAgentHandler(options) {
7234
7242
  const { threadDataToEngineMessages } = await import("./thread-data-builder.js");
7235
7243
  const priorThreadData = (await getThread(effectiveThreadId))
7236
7244
  ?.threadData;
7237
- // `threadDataToEngineMessages` takes one argument and flattens tool
7238
- // calls to text by design. The second argument never existed anywhere
7239
- // in the repo, so this failed `tsc` and broke every production build
7240
- // between 16:29 and now -- it reached main only because admin merges
7241
- // do not wait for the typecheck that would have caught it.
7242
- const resumed = threadDataToEngineMessages(priorThreadData);
7245
+ // Replay what earlier chunks DID, not just what they said: the default
7246
+ // text-only rebuild drops every tool call and result, so the next chunk
7247
+ // re-runs work already committed and cannot see its output.
7248
+ const resumed = threadDataToEngineMessages(priorThreadData, {
7249
+ includeToolCalls: true,
7250
+ });
7243
7251
  if (resumed.length > 0) {
7244
7252
  const actionPreparationTool = typeof backgroundRunMarker?.actionPreparationTool === "string" &&
7245
7253
  backgroundRunMarker.actionPreparationTool.trim()
@@ -21,6 +21,7 @@ interface ContentPart {
21
21
  approval?: {
22
22
  approvalKey: string;
23
23
  dismissed?: boolean;
24
+ askId?: string;
24
25
  };
25
26
  }
26
27
  interface BuildAssistantMessageOptions {
@@ -144,7 +144,10 @@ export function buildAssistantMessage(events, runId, options = {}) {
144
144
  const matchingIndex = findApprovalToolCallIndex(content, event.tool ?? "unknown", event.toolCallId);
145
145
  const part = content[matchingIndex];
146
146
  if (part?.type === "tool-call") {
147
- part.approval = { approvalKey: event.approvalKey };
147
+ part.approval = {
148
+ approvalKey: event.approvalKey,
149
+ ...(event.askId ? { askId: event.askId } : {}),
150
+ };
148
151
  }
149
152
  continue;
150
153
  }
@@ -744,14 +747,13 @@ function assistantReplayContent(message, text) {
744
747
  ? part.args
745
748
  : {};
746
749
  assistant.push({ type: "tool-call", id, name, input });
747
- if (part.result === undefined)
748
- continue;
750
+ const result = part.result === undefined ? INTERRUPTED_TOOL_RESULT : part.result;
749
751
  results.push({
750
752
  type: "tool-result",
751
753
  toolCallId: id,
752
754
  toolName: name,
753
755
  toolInput: stringifyToolUseInputForGateway(input),
754
- content: replayedToolResultContent(part.result),
756
+ content: replayedToolResultContent(result),
755
757
  ...(part.isError === true ? { isError: true } : {}),
756
758
  });
757
759
  }
@@ -21,7 +21,16 @@ export declare function resolveAgentToolApprovalTurnId(binding: {
21
21
  requestedTurnId?: string | null;
22
22
  approvalKeys: readonly string[];
23
23
  }): Promise<string | null>;
24
- export declare function createAgentToolApproval(binding: AgentToolApprovalBinding): Promise<void>;
24
+ /**
25
+ * Creates the durable pending-approval row and returns its id as the "ask"
26
+ * identity for this specific gate hit. Every call is a fresh row (a fresh
27
+ * id), including a second gate hit for the same `approvalKey` after a prior
28
+ * grant failed to be consumed — so the id doubles as the signal the client
29
+ * needs to tell "this approval_required is the one I already approved,
30
+ * re-rendered" apart from "this is a NEW ask for the same tool call". See
31
+ * `ApprovalAffordance` in client/chat/tool-call-display.tsx.
32
+ */
33
+ export declare function createAgentToolApproval(binding: AgentToolApprovalBinding): Promise<string>;
25
34
  /**
26
35
  * Atomically consume the server-created approval for one logical tool call.
27
36
  * The model's call id is transport metadata and can change when Dispatch
@@ -2,7 +2,13 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { getDbExec, isPostgres, retryOnDdlRace } from "../db/client.js";
3
3
  import { ensureIndexExists, ensureTableExists } from "../db/ddl-guard.js";
4
4
  import { AGENT_TOOL_APPROVAL_INDEX_SQL, AGENT_TOOL_APPROVAL_LOGICAL_INDEX_SQL, AGENT_TOOL_APPROVAL_RECOVERY_INDEX_SQL, AGENT_TOOL_APPROVAL_TABLE_SQL, } from "./tool-approval-migrations.js";
5
- const APPROVAL_TTL_MS = 15 * 60_000;
5
+ // 15 minutes was too short in practice: a user who steps away mid-approval
6
+ // (switching tabs to update their client, checking something else) comes back
7
+ // to a silently expired grant with no visible error — clicking Approve just
8
+ // does nothing, because `consumeAgentToolApproval`'s `expires_at > ?` no
9
+ // longer matches. An hour gives real human latency room while still bounding
10
+ // how long a stale grant can be replayed.
11
+ const APPROVAL_TTL_MS = 60 * 60_000;
6
12
  const APPROVAL_CLEANUP_AGE_MS = 24 * 60 * 60_000;
7
13
  let initPromise;
8
14
  async function ensureAgentToolApprovalTable() {
@@ -74,17 +80,27 @@ export async function resolveAgentToolApprovalTurnId(binding) {
74
80
  return requestedTurnId;
75
81
  return turnIds.size === 1 ? [...turnIds][0] : null;
76
82
  }
83
+ /**
84
+ * Creates the durable pending-approval row and returns its id as the "ask"
85
+ * identity for this specific gate hit. Every call is a fresh row (a fresh
86
+ * id), including a second gate hit for the same `approvalKey` after a prior
87
+ * grant failed to be consumed — so the id doubles as the signal the client
88
+ * needs to tell "this approval_required is the one I already approved,
89
+ * re-rendered" apart from "this is a NEW ask for the same tool call". See
90
+ * `ApprovalAffordance` in client/chat/tool-call-display.tsx.
91
+ */
77
92
  export async function createAgentToolApproval(binding) {
78
93
  await ensureAgentToolApprovalTable();
79
94
  const now = Date.now();
80
95
  const expiresAt = binding.expiresAt ?? now + APPROVAL_TTL_MS;
96
+ const id = `agent-tool-approval-${randomUUID()}`;
81
97
  await getDbExec().execute({
82
98
  sql: `INSERT INTO agent_tool_approvals
83
99
  (id, owner_email, org_id, thread_id, turn_id, tool_name, call_id,
84
100
  approval_key_hash, status, expires_at, consumed_at, created_at, updated_at)
85
101
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, NULL, ?, ?)`,
86
102
  args: [
87
- `agent-tool-approval-${randomUUID()}`,
103
+ id,
88
104
  binding.ownerEmail,
89
105
  binding.orgId ?? null,
90
106
  binding.threadId ?? null,
@@ -110,6 +126,7 @@ export async function createAgentToolApproval(binding) {
110
126
  catch (error) {
111
127
  console.warn("[agent] Could not clean expired tool approval records", error);
112
128
  }
129
+ return id;
113
130
  }
114
131
  /**
115
132
  * Atomically consume the server-created approval for one logical tool call.
@@ -305,6 +305,15 @@ export type AgentChatEvent = {
305
305
  approvalKey: string;
306
306
  /** The model-side tool-call id for this paused call, when available. */
307
307
  toolCallId?: string;
308
+ /**
309
+ * Identifies THIS gate hit, distinct from any earlier ask carrying the
310
+ * same `approvalKey`/`toolCallId`. A failed resume (expired grant,
311
+ * turn-id mismatch) re-emits `approval_required` for the same call with
312
+ * a fresh `askId`; the client uses the change to tell that apart from
313
+ * the same ask simply re-rendering, so a stale "approved" mark can't
314
+ * permanently hide Approve/Deny with no way to retry.
315
+ */
316
+ askId?: string;
308
317
  } | {
309
318
  type: "agent_call";
310
319
  agent: string;
@@ -1371,8 +1371,16 @@ export function useAutoResumeStatus(tabId, forceStopped) {
1371
1371
  }, [clearAutoResume, forceStopped]);
1372
1372
  return { isAutoResuming, clearAutoResume };
1373
1373
  }
1374
- function approvalResolutionIdentity(approvalKey, toolCallId) {
1375
- return `${toolCallId ?? ""}\u0000${approvalKey}`;
1374
+ function approvalResolutionIdentity(approvalKey, toolCallId,
1375
+ // Included so a NEW `approval_required` ask for the same toolCallId/
1376
+ // approvalKey (the server re-emitting after a failed resume never
1377
+ // consumed the earlier grant) looks up as unresolved instead of
1378
+ // inheriting the earlier ask's retained "approved" mark forever. Two
1379
+ // asks sharing no askId (older events, non-production-agent approval
1380
+ // sources) still collapse onto the same identity, preserving the
1381
+ // existing remount-survives-as-approved behavior for them.
1382
+ askId) {
1383
+ return `${toolCallId ?? ""}\u0000${approvalKey}\u0000${askId ?? ""}`;
1376
1384
  }
1377
1385
  const AssistantChatInner = forwardRef(function AssistantChatInner({ emptyStateText, suggestions, dynamicSuggestions, threadFooterSlot, emptyStateAddon, showHeader = true, onSwitchToCli, className, apiUrl, tabId, browserTabId, threadId, contextScope, contextNamespace, isActiveComposer = true, onMessageCountChange, onSaveThread, onGenerateTitle, composerSlot, onComposerTextChange, composerAreaClassName, composerPlaceholder, missingApiKeySetupLayout = "default", composerLayoutVariant = "default", centerComposerWhenEmpty = false, emptyStateDisplay = "default", composerToolbarSlot, composerExtraActionButton, showModelSelector = true, composerDisabled = false, composerDisabledPlaceholder, isNewThread, isThreadStateLoading, onSlashCommand, execMode, onExecModeChange, approvalActions, planModeDisabled, planModeDisabledReason, selectedModel, defaultModel, selectedEngine, selectedEffort, availableModels, modelListLoading, onModelChange, onEffortChange, availableAgents, selectedAgent, hostedHarness, onAgentChange, imageModelMenu, onForkChat, onConnectProvider, onConnectLocalRuntime, plusMenuMode = "full", providerStatusChecksEnabled = true, loadHistoryRepository, historyReloadKey, externalStreaming = false, agentChatSurface = "app", suppressInlineOpenApp = false, }, ref) {
1378
1386
  const t = useT();
@@ -4131,19 +4139,19 @@ const AssistantChatInner = forwardRef(function AssistantChatInner({ emptyStateTe
4131
4139
  scope: approvalResolutionScope,
4132
4140
  byIdentity: new Map(),
4133
4141
  }));
4134
- const getApprovalResolution = useCallback((approvalKey, toolCallId) => {
4142
+ const getApprovalResolution = useCallback((approvalKey, toolCallId, askId) => {
4135
4143
  if (approvalResolutionState.scope !== approvalResolutionScope) {
4136
4144
  return null;
4137
4145
  }
4138
- return (approvalResolutionState.byIdentity.get(approvalResolutionIdentity(approvalKey, toolCallId)) ?? null);
4146
+ return (approvalResolutionState.byIdentity.get(approvalResolutionIdentity(approvalKey, toolCallId, askId)) ?? null);
4139
4147
  }, [approvalResolutionScope, approvalResolutionState]);
4140
- const recordApprovalResolution = useCallback((approvalKey, resolution, toolCallId) => {
4148
+ const recordApprovalResolution = useCallback((approvalKey, resolution, toolCallId, askId) => {
4141
4149
  setApprovalResolutionState((previous) => {
4142
4150
  const byIdentity = previous.scope === approvalResolutionScope
4143
4151
  ? previous.byIdentity
4144
4152
  : new Map();
4145
4153
  const next = new Map(byIdentity);
4146
- next.set(approvalResolutionIdentity(approvalKey, toolCallId), resolution);
4154
+ next.set(approvalResolutionIdentity(approvalKey, toolCallId, askId), resolution);
4147
4155
  return { scope: approvalResolutionScope, byIdentity: next };
4148
4156
  });
4149
4157
  }, [approvalResolutionScope]);
@@ -0,0 +1,15 @@
1
+ import type { AgentNativeDeploymentEnvironment, AgentNativeConfig } from "../config.js";
2
+ export interface EnvironmentBadgeTargets {
3
+ betaHost: string;
4
+ productionHost: string;
5
+ }
6
+ export declare function isBuilderIoEmployee(email: string | null | undefined): boolean;
7
+ export declare function resolveEnvironmentTargets(hostname: string | undefined): EnvironmentBadgeTargets | null;
8
+ export declare function resolveEnvironmentChannel(config: AgentNativeConfig, hostname: string | undefined): Extract<AgentNativeDeploymentEnvironment, "beta" | "production"> | null;
9
+ export declare function buildEnvironmentUrl(sourceHref: string, targetHost: string): string | null;
10
+ /**
11
+ * Small internal-only lane switcher for first-party hosted Agent-Native apps.
12
+ * It is mounted inside the authenticated AppProviders shell so public pages,
13
+ * embeds, and signed-out visitors never receive environment navigation.
14
+ */
15
+ export declare function EnvironmentBadge(): import("react").JSX.Element | null;
@@ -0,0 +1,79 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button } from "@agent-native/toolkit/ui/button";
3
+ import { Popover, PopoverContent, PopoverTrigger, } from "@agent-native/toolkit/ui/popover";
4
+ import { IconExternalLink, IconGitBranch } from "@tabler/icons-react";
5
+ import { useMemo } from "react";
6
+ import { injectedAgentNativeConfig } from "./app-config.js";
7
+ import { useSession } from "./use-session.js";
8
+ export function isBuilderIoEmployee(email) {
9
+ return email?.trim().toLowerCase().endsWith("@builder.io") ?? false;
10
+ }
11
+ export function resolveEnvironmentTargets(hostname) {
12
+ const normalized = hostname?.trim().toLowerCase().replace(/\.$/, "");
13
+ if (!normalized)
14
+ return null;
15
+ const productionHost = normalized.replace(/^beta\./, "");
16
+ const isFirstPartyHost = productionHost.endsWith(".agent-native.com") ||
17
+ productionHost === "agent-workspace.builder.io";
18
+ if (!isFirstPartyHost)
19
+ return null;
20
+ return {
21
+ betaHost: `beta.${productionHost}`,
22
+ productionHost,
23
+ };
24
+ }
25
+ export function resolveEnvironmentChannel(config, hostname) {
26
+ const configured = config.deployment?.environment;
27
+ if (configured === "beta" || configured === "production") {
28
+ return configured;
29
+ }
30
+ const targets = resolveEnvironmentTargets(hostname);
31
+ if (!targets || !hostname)
32
+ return null;
33
+ return hostname.trim().toLowerCase().startsWith("beta.")
34
+ ? "beta"
35
+ : "production";
36
+ }
37
+ export function buildEnvironmentUrl(sourceHref, targetHost) {
38
+ try {
39
+ const target = new URL(sourceHref);
40
+ target.protocol = "https:";
41
+ target.hostname = targetHost;
42
+ target.port = "";
43
+ return target.toString();
44
+ }
45
+ catch {
46
+ // coercion-ok: Invalid navigation input is an explicit absent target.
47
+ return null;
48
+ }
49
+ }
50
+ function EnvironmentLink({ label, href }) {
51
+ return (_jsx(Button, { asChild: true, className: "w-full justify-start px-2", size: "sm", variant: "ghost", children: _jsxs("a", { href: href, children: [_jsx(IconExternalLink, { "aria-hidden": "true" }), label] }) }));
52
+ }
53
+ /**
54
+ * Small internal-only lane switcher for first-party hosted Agent-Native apps.
55
+ * It is mounted inside the authenticated AppProviders shell so public pages,
56
+ * embeds, and signed-out visitors never receive environment navigation.
57
+ */
58
+ export function EnvironmentBadge() {
59
+ const { session, status } = useSession();
60
+ const config = useMemo(injectedAgentNativeConfig, []);
61
+ const environment = resolveEnvironmentChannel(config, typeof window === "undefined" ? undefined : window.location.hostname);
62
+ const targets = resolveEnvironmentTargets(typeof window === "undefined" ? undefined : window.location.hostname);
63
+ if (typeof window === "undefined" ||
64
+ window.parent !== window ||
65
+ status !== "authenticated" ||
66
+ !isBuilderIoEmployee(session?.email) ||
67
+ !environment ||
68
+ !targets) {
69
+ return null;
70
+ }
71
+ const currentHref = window.location.href;
72
+ const betaHref = buildEnvironmentUrl(currentHref, targets.betaHost);
73
+ const productionHref = buildEnvironmentUrl(currentHref, targets.productionHost);
74
+ if (!betaHref || !productionHref)
75
+ return null;
76
+ const label = environment === "beta" ? "beta" : "prod";
77
+ const title = environment === "beta" ? "Beta environment" : "Production environment";
78
+ return (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { "aria-label": `Open ${title.toLowerCase()} switcher`, className: "fixed bottom-3 right-3 z-[100] h-7 rounded-full border-border/80 bg-background/95 px-2.5 text-[11px] font-semibold uppercase tracking-[0.12em] shadow-sm backdrop-blur-sm", size: "sm", variant: environment === "beta" ? "secondary" : "outline", children: [_jsx("span", { "aria-hidden": "true", className: `size-1.5 rounded-full ${environment === "beta" ? "bg-primary" : "bg-muted-foreground"}` }), label] }) }), _jsxs(PopoverContent, { align: "end", className: "w-64 p-3", side: "top", children: [_jsxs("div", { className: "mb-2 flex items-center gap-2 text-sm font-medium", children: [_jsx(IconGitBranch, { "aria-hidden": "true", className: "size-4 text-muted-foreground" }), title] }), _jsx("div", { className: "grid gap-1", children: environment === "beta" ? (_jsx(EnvironmentLink, { href: productionHref, label: "Go to production" })) : (_jsx(EnvironmentLink, { href: betaHref, label: "Go to beta" })) })] })] }));
79
+ }
@@ -291,6 +291,22 @@ const DEFAULT_AGENT_TEAM_POLL_MS = 3000;
291
291
  const DEFAULT_THREAD_URL_PARAM = "thread";
292
292
  const THREAD_URL_CHANGED_EVENT = "agent-chat:url-thread-changed";
293
293
  const hasOwn = Object.prototype.hasOwnProperty;
294
+ // A duplicated id in `openTabIds` makes two tab-bar entries share one
295
+ // underlying thread: closing either one filters that id out of the array
296
+ // entirely, so both disappear at once. De-duplicate at every boundary the
297
+ // array crosses (localStorage read and write) so a corrupted persisted list
298
+ // self-heals instead of reproducing the phantom tab on every reload.
299
+ function dedupeIds(ids) {
300
+ const seen = new Set();
301
+ const result = [];
302
+ for (const id of ids) {
303
+ if (seen.has(id))
304
+ continue;
305
+ seen.add(id);
306
+ result.push(id);
307
+ }
308
+ return result;
309
+ }
294
310
  // The history patch is installed once and shared via a ref count so that
295
311
  // multiple synced chats (or a remount) don't restore a stale `pushState`
296
312
  // reference and silently drop a wrapper installed by another instance.
@@ -794,7 +810,7 @@ export function MultiTabAssistantChat({ showTabBar = true, renderHeader, renderO
794
810
  // (and rebroadcasting their run state) while another resource is open.
795
811
  const scopeKeyPart = scope ? `:scope:${scope.type}:${scope.id}` : "";
796
812
  const OPEN_TABS_KEY = `agent-chat-open-tabs${keyPrefix}${scopeKeyPart}`;
797
- const [openTabIds, setOpenTabIds] = useState(() => {
813
+ const [openTabIds, setOpenTabIdsRaw] = useState(() => {
798
814
  if (!restoreActiveThread && activeThreadId) {
799
815
  for (const id of [activeThreadId])
800
816
  mountedTabsRef.current.add(id);
@@ -805,16 +821,35 @@ export function MultiTabAssistantChat({ showTabBar = true, renderHeader, renderO
805
821
  if (saved) {
806
822
  const parsed = JSON.parse(saved);
807
823
  if (Array.isArray(parsed) && parsed.length > 0) {
824
+ const deduped = dedupeIds(parsed);
808
825
  // Mark restored tabs as mounted
809
- for (const id of parsed)
826
+ for (const id of deduped)
810
827
  mountedTabsRef.current.add(id);
811
- return parsed;
828
+ return deduped;
812
829
  }
813
830
  }
814
831
  }
815
832
  catch { }
816
833
  return [];
817
834
  });
835
+ // Every writer routes through here so a duplicate id can never reach state.
836
+ // A `.includes()` guard evaluated outside the updater cannot prevent one: a
837
+ // synchronous burst of writes (the mount-time replay of buffered open
838
+ // requests dispatches its whole backlog in one loop) has every handler read
839
+ // the same pre-render list, so each one appends. Two tab-bar entries then
840
+ // share a single thread id and `closeTab`'s filter removes both at once.
841
+ // Returning `prev` unchanged is load-bearing, not an optimization — callers
842
+ // rely on identity bail-outs to keep effects that depend on `openTabIds`
843
+ // from re-running forever.
844
+ const setOpenTabIds = useCallback((value) => {
845
+ setOpenTabIdsRaw((prev) => {
846
+ const next = dedupeIds(typeof value === "function" ? value(prev) : value);
847
+ return next.length === prev.length &&
848
+ next.every((id, i) => id === prev[i])
849
+ ? prev
850
+ : next;
851
+ });
852
+ }, []);
818
853
  const openTabIdsRef = useRef(openTabIds);
819
854
  openTabIdsRef.current = openTabIds;
820
855
  const initializedRef = useRef(false);
@@ -982,6 +1017,7 @@ export function MultiTabAssistantChat({ showTabBar = true, renderHeader, renderO
982
1017
  // optimistic id for a brand-new session); the activeThreadId effect above
983
1018
  // adds it to openTabIds without spinning up a duplicate thread.
984
1019
  const autoCreatingRef = useRef(false);
1020
+ const lastTabReplacementInFlightRef = useRef(false);
985
1021
  useEffect(() => {
986
1022
  if (isLoading || autoCreatingRef.current)
987
1023
  return;
@@ -1435,25 +1471,45 @@ export function MultiTabAssistantChat({ showTabBar = true, renderHeader, renderO
1435
1471
  });
1436
1472
  }, []);
1437
1473
  const closeTab = useCallback((tabId) => {
1438
- setOpenTabIds((prev) => {
1439
- if (prev.length <= 1) {
1440
- // Last tab — create a new one and replace the old tab atomically
1441
- createThread().then((newId) => {
1474
+ // Read the current list from the ref rather than a `setOpenTabIds`
1475
+ // updater callback — `createThread()`/`switchThread()` below set
1476
+ // `activeThreadId` synchronously as a side effect, and calling them
1477
+ // from inside an updater left `openTabIds` and `activeThreadId`
1478
+ // inconsistent for a render: the "ensure active thread is in open
1479
+ // tabs" effect would then re-add the just-closed id, so the tab
1480
+ // appeared to reopen itself right after closing.
1481
+ const prev = openTabIdsRef.current;
1482
+ if (prev.length <= 1) {
1483
+ if (lastTabReplacementInFlightRef.current)
1484
+ return;
1485
+ lastTabReplacementInFlightRef.current = true;
1486
+ // Last tab — create a new one and replace the old tab once ready;
1487
+ // the old tab stays visible in the meantime so the bar is never empty.
1488
+ cleanupClosedTab(tabId);
1489
+ void (async () => {
1490
+ try {
1491
+ const newId = await createThread();
1442
1492
  if (newId) {
1443
1493
  newThreadIds.current.add(newId);
1444
1494
  setOpenTabIds([newId]);
1445
1495
  writeThreadUrl(null);
1446
1496
  }
1447
- });
1448
- return prev; // Keep old tab until new one is ready
1449
- }
1450
- const next = prev.filter((id) => id !== tabId);
1451
- if (tabId === activeThreadIdRef.current && next.length > 0) {
1452
- const idx = prev.indexOf(tabId);
1453
- switchThread(next[Math.min(idx, next.length - 1)]);
1454
- }
1455
- return next;
1456
- });
1497
+ }
1498
+ catch (error) {
1499
+ console.error("[agent-chat] failed to replace the closed final tab", error);
1500
+ }
1501
+ finally {
1502
+ lastTabReplacementInFlightRef.current = false;
1503
+ }
1504
+ })();
1505
+ return;
1506
+ }
1507
+ const next = prev.filter((id) => id !== tabId);
1508
+ if (tabId === activeThreadIdRef.current && next.length > 0) {
1509
+ const idx = prev.indexOf(tabId);
1510
+ switchThread(next[Math.min(idx, next.length - 1)]);
1511
+ }
1512
+ setOpenTabIds(next);
1457
1513
  cleanupClosedTab(tabId);
1458
1514
  }, [switchThread, createThread, cleanupClosedTab, writeThreadUrl]);
1459
1515
  const closeOtherTabs = useCallback((tabId) => {
@@ -65,7 +65,17 @@ const MAX_HISTORY_ATTACHMENT_CHARS = 60_000;
65
65
  // the trailing truncation notice still makes a pathological multi-MB paste
66
66
  // visibly (not silently) capped.
67
67
  const MAX_OUTBOUND_ATTACHMENT_CHARS = 200_000;
68
- const MAX_HISTORY_MESSAGES = 24;
68
+ // An array-length backstop, NOT the reduction policy. Reducing a long thread is
69
+ // Observational Memory's job: it folds older turns into observations/reflections
70
+ // and replaces the raw prefix with a memory block plus a recent-raw window
71
+ // (agent/observational-memory/). But its Observer only engages once a thread
72
+ // passes 30k unobserved tokens, and a count cap of 24 bit long before that — so
73
+ // turns were evicted from the request while compaction still had nothing to say
74
+ // about them, and only reached the model again once thread_data was observed a
75
+ // turn or more later. The two char budgets below are the real bound (they cap
76
+ // what a request can carry regardless of message count); this cap only keeps the
77
+ // array from growing without limit.
78
+ const MAX_HISTORY_MESSAGES = 80;
69
79
  // Every provider prompt cache matches a byte-identical PREFIX. A window that
70
80
  // ends at the newest message and starts MAX_HISTORY_MESSAGES back moves its
71
81
  // START by one message per turn, so from the first turn past the cap onward no
@@ -48,7 +48,20 @@ function workspacePathBasePath() {
48
48
  const segment = window.location.pathname.split("/").find(Boolean);
49
49
  if (!segment || segment === "_agent-native" || segment === "api")
50
50
  return "";
51
- return normalizeBasePath(segment);
51
+ const basePath = normalizeBasePath(segment);
52
+ // Guard against treating an app-local route (e.g. a client-rendered
53
+ // "/settings" page reached via stale client-side navigation) as if it
54
+ // were a sibling app's workspace mount — that built URLs like
55
+ // "/settings/_agent-native/builder/connect", which the workspace gateway
56
+ // 404s (no app is mounted at "/settings") into its app-picker page instead
57
+ // of the real target route. Only trust the segment when it matches a
58
+ // known mount from the deployed app manifest; when the manifest can't be
59
+ // read, fall back to the prior blind-trust behavior (e.g. the same build
60
+ // reused across sibling app ids without a manifest).
61
+ const mounts = workspaceAppMountPaths();
62
+ if (mounts && !mounts.has(basePath))
63
+ return "";
64
+ return basePath;
52
65
  }
53
66
  function externalEmbedTargetBasePath() {
54
67
  if (typeof window === "undefined")
@@ -57,6 +57,7 @@ import { useInRouterContext } from "react-router";
57
57
  import { isHumanReadableDocumentTitle, normalizeDocumentTitle, } from "../shared/document-title.js";
58
58
  import { ClientOnly } from "./ClientOnly.js";
59
59
  import { DefaultSpinner } from "./DefaultSpinner.js";
60
+ import { EnvironmentBadge } from "./EnvironmentBadge.js";
60
61
  import { AgentNativeI18nProvider, } from "./i18n.js";
61
62
  import { FirstRunOnboardingStartupGate } from "./onboarding/first-run-startup-gate.js";
62
63
  import { RequireSession } from "./require-session.js";
@@ -151,5 +152,5 @@ export function AppProviders({ queryClient, isPublicPath = false, clientOnlyFall
151
152
  if (isPublicPath) {
152
153
  return (_jsx(ProvidersInner, { queryClient: queryClient, defaultTheme: defaultTheme, themeAttribute: themeAttribute, tooltipDelayDuration: tooltipDelayDuration, toaster: toaster, disableThemeTransitions: disableThemeTransitions, i18n: i18n, documentTitleFallback: documentTitleFallback, children: children }));
153
154
  }
154
- return (_jsx(ClientOnly, { fallback: fallback, children: _jsx(ProvidersInner, { queryClient: queryClient, defaultTheme: defaultTheme, themeAttribute: themeAttribute, tooltipDelayDuration: tooltipDelayDuration, toaster: toaster, disableThemeTransitions: disableThemeTransitions, i18n: i18n, documentTitleFallback: documentTitleFallback, children: _jsx(RequireSession, { bypass: sessionBypass, fallback: fallback, children: sessionBypass ? (children) : (_jsx(FirstRunOnboardingStartupGate, { children: children })) }) }) }));
155
+ return (_jsx(ClientOnly, { fallback: fallback, children: _jsx(ProvidersInner, { queryClient: queryClient, defaultTheme: defaultTheme, themeAttribute: themeAttribute, tooltipDelayDuration: tooltipDelayDuration, toaster: toaster, disableThemeTransitions: disableThemeTransitions, i18n: i18n, documentTitleFallback: documentTitleFallback, children: _jsx(RequireSession, { bypass: sessionBypass, fallback: fallback, children: sessionBypass ? (children) : (_jsxs(FirstRunOnboardingStartupGate, { children: [children, _jsx(EnvironmentBadge, {})] })) }) }) }));
155
156
  }
@@ -34,9 +34,17 @@ export type ApprovalContextValue = {
34
34
  * Keep the visible resolution stable while the chat repository refreshes or
35
35
  * remounts the message containing this approval card.
36
36
  */
37
- onApprovalResolved?: (approvalKey: string, resolution: ApprovalResolution, toolCallId?: string) => void;
37
+ onApprovalResolved?: (approvalKey: string, resolution: ApprovalResolution, toolCallId?: string,
38
+ /**
39
+ * Identifies the specific `approval_required` ask being resolved. A
40
+ * remount that replays the SAME ask (e.g. a chat repository refresh)
41
+ * omits nothing new here, so the retained resolution still matches; a
42
+ * fresh ask after a failed resume carries a different `askId` and so
43
+ * looks up as unresolved. See `ApprovalAffordance` below.
44
+ */
45
+ askId?: string) => void;
38
46
  /** Read a resolution retained by the owning chat surface. */
39
- getApprovalResolution?: (approvalKey: string, toolCallId?: string) => ApprovalResolution | null;
47
+ getApprovalResolution?: (approvalKey: string, toolCallId?: string, askId?: string) => ApprovalResolution | null;
40
48
  /**
41
49
  * Optional host hook invoked in addition to the local "denied" state, e.g.
42
50
  * so a Code session can also resolve its own pending approval as denied.
@@ -97,6 +105,7 @@ export declare function ToolCallDisplay({ toolName, toolCallId, argsText, args,
97
105
  approval?: {
98
106
  approvalKey: string;
99
107
  dismissed?: boolean;
108
+ askId?: string;
100
109
  };
101
110
  repeatCount?: number;
102
111
  /** The latest tool shown while the overall chat turn is still active. */
@@ -113,6 +122,7 @@ export declare function ToolCallFallback({ toolName, toolCallId, args, argsText,
113
122
  approval?: {
114
123
  approvalKey: string;
115
124
  dismissed?: boolean;
125
+ askId?: string;
116
126
  };
117
127
  repeatCount?: number;
118
128
  isLatestRunning?: boolean;