@agent-native/core 0.133.0 → 0.133.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +26 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/a2a/client.ts +22 -5
  5. package/corpus/core/src/agent/engine/ai-sdk-engine.ts +28 -0
  6. package/corpus/core/src/agent/run-store.ts +19 -4
  7. package/corpus/core/src/integrations/webhook-handler.ts +7 -0
  8. package/corpus/core/src/scripts/call-agent.ts +29 -2
  9. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +1 -34
  10. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +77 -3
  11. package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +4 -211
  12. package/corpus/templates/content/changelog/2026-07-30-deleting-a-page-no-longer-leaves-the-content-sidebar-unrespo.md +6 -0
  13. package/corpus/templates/content/e2e/playwright.config.ts +2 -1
  14. package/dist/a2a/client.d.ts +9 -1
  15. package/dist/a2a/client.d.ts.map +1 -1
  16. package/dist/a2a/client.js +12 -4
  17. package/dist/a2a/client.js.map +1 -1
  18. package/dist/agent/engine/ai-sdk-engine.d.ts.map +1 -1
  19. package/dist/agent/engine/ai-sdk-engine.js +24 -0
  20. package/dist/agent/engine/ai-sdk-engine.js.map +1 -1
  21. package/dist/agent/run-store.d.ts.map +1 -1
  22. package/dist/agent/run-store.js +18 -3
  23. package/dist/agent/run-store.js.map +1 -1
  24. package/dist/collab/awareness.d.ts +2 -2
  25. package/dist/collab/awareness.d.ts.map +1 -1
  26. package/dist/collab/routes.d.ts +1 -1
  27. package/dist/collab/struct-routes.d.ts +1 -1
  28. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  29. package/dist/integrations/webhook-handler.d.ts.map +1 -1
  30. package/dist/integrations/webhook-handler.js +3 -0
  31. package/dist/integrations/webhook-handler.js.map +1 -1
  32. package/dist/notifications/routes.d.ts +3 -3
  33. package/dist/observability/routes.d.ts +3 -3
  34. package/dist/scripts/call-agent.d.ts.map +1 -1
  35. package/dist/scripts/call-agent.js +24 -2
  36. package/dist/scripts/call-agent.js.map +1 -1
  37. package/dist/server/transcribe-voice.d.ts +1 -1
  38. package/package.json +1 -1
  39. package/src/a2a/client.ts +22 -5
  40. package/src/agent/engine/ai-sdk-engine.ts +28 -0
  41. package/src/agent/run-store.ts +19 -4
  42. package/src/integrations/webhook-handler.ts +7 -0
  43. package/src/scripts/call-agent.ts +29 -2
package/corpus/README.md CHANGED
@@ -30,4 +30,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
30
30
 
31
31
  - core files: 1646
32
32
  - toolkit files: 168
33
- - template files: 7413
33
+ - template files: 7414
@@ -1,5 +1,31 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.133.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 3fac05d: Ensure Netlify-hosted integration calls hand off slow cross-app work to durable delivery when only runtime markers are available.
8
+
9
+ ## 0.133.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 1c08605: Fail closed when an `ai-sdk:*` engine has no provider key, instead of sending an
14
+ unauthenticated request. The provider factory was previously built with no
15
+ `apiKey`, so the SDK omitted the Authorization header and the gateway's 401 came
16
+ back as `http_401` "Missing Authentication header" — a transport error naming the
17
+ wrong cause, which a scheduled job then retried on every tick forever. It now
18
+ reports `missing_credentials` and names the env var it wants, matching what
19
+ `builder-engine` and `anthropic-engine` already did.
20
+
21
+ Also stop reaping in-process background automations (scheduler and trigger runs)
22
+ at the tight 45s post-claim stale window. That window exists to reach a durable
23
+ successor sooner, but these runs carry no `dispatch_payload` and have no
24
+ successor to reach, so an early reap killed still-working jobs that nothing could
25
+ recover. They now get the 90s background window, and the recovery path reports
26
+ `not_redispatchable` rather than `payload_missing`, which read as data loss for
27
+ the one case where nothing was ever lost.
28
+
3
29
  ## 0.133.0
4
30
 
5
31
  ### Minor Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.133.0",
3
+ "version": "0.133.2",
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": {
@@ -536,8 +536,14 @@ export class A2AClient {
536
536
  metadata?: Record<string, unknown>;
537
537
  idempotencyKey?: string;
538
538
  approvedActions?: A2AApprovedAction[];
539
- /** Total time to wait for completion. Default 5 min. */
539
+ /** Time to wait after submission for completion. Default 5 min. */
540
540
  timeoutMs?: number;
541
+ /**
542
+ * Optional separate budget for agent-card discovery and the initial
543
+ * async message submission. When omitted, timeoutMs remains the shared
544
+ * end-to-end deadline for backwards compatibility.
545
+ */
546
+ submissionTimeoutMs?: number;
541
547
  /** Poll interval. Default 2s. */
542
548
  pollIntervalMs?: number;
543
549
  /** Called with each polled task — useful for surfacing progress. */
@@ -545,7 +551,8 @@ export class A2AClient {
545
551
  },
546
552
  ): Promise<Task> {
547
553
  const timeoutMs = opts?.timeoutMs ?? 5 * 60_000;
548
- const deadlineMs = Date.now() + timeoutMs;
554
+ const submissionDeadlineMs =
555
+ Date.now() + (opts?.submissionTimeoutMs ?? timeoutMs);
549
556
  const submitted = await this.send(message, {
550
557
  contextId: opts?.contextId,
551
558
  metadata: opts?.metadata,
@@ -556,12 +563,19 @@ export class A2AClient {
556
563
  async: true,
557
564
  requestTimeoutMs: Math.min(
558
565
  this.requestTimeoutMs ?? DEFAULT_A2A_POLL_REQUEST_TIMEOUT_MS,
559
- Math.max(1, deadlineMs - Date.now()),
566
+ Math.max(1, submissionDeadlineMs - Date.now()),
560
567
  ),
561
- deadlineMs,
568
+ deadlineMs: submissionDeadlineMs,
562
569
  });
563
570
 
564
- return this.pollTask(submitted, { ...opts, timeoutMs, deadlineMs });
571
+ const pollingDeadlineMs = opts?.submissionTimeoutMs
572
+ ? Date.now() + timeoutMs
573
+ : submissionDeadlineMs;
574
+ return this.pollTask(submitted, {
575
+ ...opts,
576
+ timeoutMs,
577
+ deadlineMs: pollingDeadlineMs,
578
+ });
565
579
  }
566
580
 
567
581
  /**
@@ -1017,6 +1031,8 @@ export async function callAgent(
1017
1031
  async?: boolean;
1018
1032
  /** Total time to wait for the polled task (default 5 min). */
1019
1033
  timeoutMs?: number;
1034
+ /** Separate budget for discovery and initial async submission. */
1035
+ submissionTimeoutMs?: number;
1020
1036
  /**
1021
1037
  * Existing async task to keep polling. When set, no new message is sent.
1022
1038
  * This prevents a caller-side timeout from duplicating downstream work.
@@ -1092,6 +1108,7 @@ export async function callAgent(
1092
1108
  ? { approvedActions: opts.approvedActions }
1093
1109
  : {}),
1094
1110
  timeoutMs: opts?.timeoutMs,
1111
+ submissionTimeoutMs: opts?.submissionTimeoutMs,
1095
1112
  pollIntervalMs: opts?.pollIntervalMs,
1096
1113
  onUpdate: opts?.onUpdate,
1097
1114
  });
@@ -23,6 +23,10 @@ import {
23
23
  supportsClaudeAdaptiveThinking,
24
24
  } from "../../shared/reasoning-effort.js";
25
25
  import { AI_SDK_MODEL_CONFIG, type AISDKProvider } from "../model-config.js";
26
+ import {
27
+ LLM_MISSING_CREDENTIALS_ERROR_CODE,
28
+ LLM_MISSING_CREDENTIALS_MESSAGE,
29
+ } from "./credential-errors.js";
26
30
  import {
27
31
  classifyProviderError,
28
32
  describeErrorWithCauses,
@@ -228,6 +232,8 @@ class AISDKEngine implements AgentEngine {
228
232
  private readonly provider: AISDKProvider;
229
233
  private readonly apiKey?: string;
230
234
  private readonly baseUrl?: string;
235
+ /** Empty for providers that need no key (ollama). */
236
+ private readonly requiredEnvVars: readonly string[];
231
237
  private readonly appName?: string;
232
238
  private readonly appUrl?: string;
233
239
 
@@ -243,12 +249,34 @@ class AISDKEngine implements AgentEngine {
243
249
  this.apiKey =
244
250
  config.apiKey ??
245
251
  (config.allowEnvFallback === false ? "" : getProviderApiKey(provider));
252
+ this.requiredEnvVars = PROVIDER_ENV_VARS[provider];
246
253
  this.baseUrl = config.baseUrl;
247
254
  this.appName = config.appName;
248
255
  this.appUrl = config.appUrl;
249
256
  }
250
257
 
251
258
  async *stream(opts: EngineStreamOptions): AsyncIterable<EngineEvent> {
259
+ // An absent key is not an anonymous request. Without this the provider
260
+ // factory is constructed with no `apiKey`, the SDK omits the Authorization
261
+ // header entirely, and the gateway's 401 comes back as
262
+ // "Missing Authentication header" — which `classifyProviderError` codes
263
+ // `http_401`, a transport failure naming the wrong cause. A scheduled job
264
+ // then repeats that doomed unauthenticated request on every tick forever.
265
+ // `builder-engine` and `anthropic-engine` already fail closed here; this
266
+ // engine was the only one that did not.
267
+ //
268
+ // A configured `baseUrl` is exempt: a self-hosted or local gateway may
269
+ // legitimately accept unauthenticated requests.
270
+ if (!this.apiKey && !this.baseUrl && this.requiredEnvVars.length > 0) {
271
+ yield {
272
+ type: "stop",
273
+ reason: "error",
274
+ error: `${LLM_MISSING_CREDENTIALS_MESSAGE} (engine "${this.name}" has no ${this.requiredEnvVars.join(" or ")})`,
275
+ errorCode: LLM_MISSING_CREDENTIALS_ERROR_CODE,
276
+ };
277
+ return;
278
+ }
279
+
252
280
  let aiModule: any;
253
281
  try {
254
282
  aiModule = await import("ai");
@@ -736,7 +736,13 @@ function backgroundAwareStaleCutoffSql(): string {
736
736
  // `CAST(? AS BIGINT)` is required: without it Postgres infers the param as
737
737
  // int4 from the int4 window literals, so the bound `Date.now()` ms epoch
738
738
  // overflows int4. The cast keeps the subtraction 64-bit; a no-op on SQLite.
739
- return `(CAST(? AS BIGINT) - CASE WHEN dispatch_mode = 'background-processing' THEN ${BACKGROUND_PROCESSING_RUN_STALE_MS} WHEN dispatch_mode LIKE 'background%' THEN ${BACKGROUND_RUN_STALE_MS} ELSE ${RUN_STALE_MS} END)`;
739
+ // The tight post-claim window buys ONE thing: reaching the durable successor
740
+ // sooner (see BACKGROUND_PROCESSING_RUN_STALE_MS). A row with no
741
+ // `dispatch_payload` has no successor to reach — `attemptStaleRunRecovery`
742
+ // declines it as `not_redispatchable` — so reaping it early is pure loss: it
743
+ // kills an in-process background automation (scheduler/trigger) that is still
744
+ // working, and nothing recovers it. Those get the wider background window.
745
+ return `(CAST(? AS BIGINT) - CASE WHEN dispatch_mode = 'background-processing' AND dispatch_payload IS NOT NULL THEN ${BACKGROUND_PROCESSING_RUN_STALE_MS} WHEN dispatch_mode LIKE 'background%' THEN ${BACKGROUND_RUN_STALE_MS} ELSE ${RUN_STALE_MS} END)`;
740
746
  }
741
747
 
742
748
  function terminalRunEventExclusionSql(runIdColumn = "id"): string {
@@ -1546,7 +1552,7 @@ interface StaleRunRecoverySuccessor {
1546
1552
  type StaleRunRecoveryOutcome =
1547
1553
  | ({ outcome: "recovered" } & StaleRunRecoverySuccessor)
1548
1554
  | { outcome: "not_background" }
1549
- | { outcome: "payload_missing" }
1555
+ | { outcome: "not_redispatchable" }
1550
1556
  | { outcome: "newer_run_exists" }
1551
1557
  | { outcome: "budget_exhausted" }
1552
1558
  | { outcome: "repeated_no_progress" };
@@ -1596,7 +1602,9 @@ function staleRecoveryDispatchPayload(payload: string): string {
1596
1602
  * - the row is a background chat-turn dispatch (`dispatch_mode` starting
1597
1603
  * with "background") — a foreground/foreground-self-chain run has a
1598
1604
  * connected client to recover it via its own `auto_continue` re-POST.
1599
- * - its `dispatch_payload` is still present without it there is nothing
1605
+ * - its `dispatch_payload` is still present (an in-process automation run
1606
+ * never had one and is declined as `not_redispatchable`, not as a loss)
1607
+ * — without it there is nothing
1600
1608
  * to rehydrate the successor's request body from. Read HERE, before the
1601
1609
  * caller's terminal write (which NULLs `dispatch_payload` on every other
1602
1610
  * path), so it survives long enough to carry over.
@@ -1633,7 +1641,14 @@ async function attemptStaleRunRecovery(
1633
1641
  }
1634
1642
  const payload = row.dispatch_payload;
1635
1643
  if (typeof payload !== "string" || payload.length === 0) {
1636
- return { outcome: "payload_missing" };
1644
+ // Not an anomaly, and specifically NOT a lost payload: every HTTP-dispatched
1645
+ // background run writes `dispatch_payload` in the same INSERT that creates
1646
+ // the row, so a payload-less row is one that was never HTTP-dispatched at
1647
+ // all — an in-process automation from `runBackgroundAutomation`. It has no
1648
+ // request body to rehydrate because there was never a request. Naming this
1649
+ // `payload_missing` read as "we are losing payloads" in production
1650
+ // forensics and sent a reader hunting a bug that does not exist.
1651
+ return { outcome: "not_redispatchable" };
1637
1652
  }
1638
1653
  const threadId = row.thread_id;
1639
1654
  const turnId = row.turn_id ?? runId;
@@ -2274,6 +2274,13 @@ function isQueuedA2AContinuationDeferral(text: string): boolean {
2274
2274
  if (!normalized) return true;
2275
2275
  if (hasSubstantiveA2APartialAnswer(text)) return false;
2276
2276
  if (normalized.includes(A2A_CONTINUATION_QUEUED_MARKER)) return true;
2277
+ if (
2278
+ /\bwill\b[^.!?]{0,160}\bpost\b[^.!?]{0,160}\b(?:thread|result|link|content id)\b/i.test(
2279
+ normalized,
2280
+ )
2281
+ ) {
2282
+ return true;
2283
+ }
2277
2284
  return /\b(?:still (?:working|processing)|is working on|taking longer than expected|will (?:post|update|surface|show up)|(?:it'?ll|it will|the result will|the final result will) (?:post|be posted|update|be updated|surface|show up)|will be (?:posted|updated|sent|shared)|final result when it finishes|while you wait|as soon as (?:it|it'?s|it is|the result|the artifact) (?:comes back|is ready|ready)|hang tight|relay from the .* agent)\b/i.test(
2278
2285
  normalized,
2279
2286
  );
@@ -38,6 +38,7 @@ import { track } from "../tracking/registry.js";
38
38
 
39
39
  const DEFAULT_SERVERLESS_INTEGRATION_A2A_TIMEOUT_MS = 18_000;
40
40
  const NETLIFY_INTEGRATION_A2A_TIMEOUT_MS = 2_000;
41
+ const NETLIFY_INTEGRATION_A2A_SUBMISSION_TIMEOUT_MS = 15_000;
41
42
  const INTEGRATION_A2A_TOKEN_TTL = "30m";
42
43
  const A2A_INVOCATION_EVENT = "$a2a_invocation";
43
44
 
@@ -167,13 +168,32 @@ function parseTimeoutMs(value: string | undefined): number | undefined {
167
168
  return Math.floor(parsed);
168
169
  }
169
170
 
171
+ function hasExplicitNonHostedNetlifyOverride(): boolean {
172
+ return (
173
+ process.env.NETLIFY_LOCAL === "true" || process.env.NETLIFY === "false"
174
+ );
175
+ }
176
+
177
+ function isNetlifyHostedRuntimeForIntegrationCall(): boolean {
178
+ if (hasExplicitNonHostedNetlifyOverride()) return false;
179
+ if (process.env.NETLIFY && process.env.NETLIFY !== "false") return true;
180
+
181
+ // NETLIFY is a build-time marker, while deployed Netlify Functions expose
182
+ // SITE_ID at runtime. Recognize the same runtime-only marker used by the
183
+ // durable background and run-manager gates so the integration caller hands
184
+ // slow A2A work to durable delivery before its foreground budget expires.
185
+ return Boolean(process.env.SITE_ID); // guard:allow-env-credential -- Netlify's read-only public site identifier is a runtime host marker, not a user credential.
186
+ }
187
+
170
188
  function isServerlessHost(): boolean {
189
+ if (hasExplicitNonHostedNetlifyOverride()) return false;
190
+
171
191
  // Detection mirrors db/migrations.ts:297-301. On Cloudflare Workers/Pages,
172
192
  // `process.env` is shimmed and CF_PAGES isn't reliably populated at runtime —
173
193
  // the canonical signal is the `__cf_env`/`__env__` global injected by the
174
194
  // Cloudflare runtime adapter.
175
195
  return (
176
- !!process.env.NETLIFY ||
196
+ isNetlifyHostedRuntimeForIntegrationCall() ||
177
197
  !!process.env.AWS_LAMBDA_FUNCTION_NAME ||
178
198
  !!process.env.VERCEL ||
179
199
  "__cf_env" in globalThis ||
@@ -193,7 +213,9 @@ function getIntegrationCallTimeoutMs(): number | undefined {
193
213
  // calls very short so multi-agent integration requests queue downstream
194
214
  // continuations quickly instead of spending the parent Slack/email processor
195
215
  // budget waiting on separately deployed apps one-by-one.
196
- if (process.env.NETLIFY) return NETLIFY_INTEGRATION_A2A_TIMEOUT_MS;
216
+ if (isNetlifyHostedRuntimeForIntegrationCall()) {
217
+ return NETLIFY_INTEGRATION_A2A_TIMEOUT_MS;
218
+ }
197
219
 
198
220
  return DEFAULT_SERVERLESS_INTEGRATION_A2A_TIMEOUT_MS;
199
221
  }
@@ -676,6 +698,10 @@ export async function run(
676
698
  // Docker can wait for slow-but-valid answers; integration processors
677
699
  // still need to finish before their current function execution dies.
678
700
  const callTimeoutMs = getIntegrationCallTimeoutMs();
701
+ const submissionTimeoutMs =
702
+ callTimeoutMs && isNetlifyHostedRuntimeForIntegrationCall()
703
+ ? NETLIFY_INTEGRATION_A2A_SUBMISSION_TIMEOUT_MS
704
+ : undefined;
679
705
  responseText = await callAgent(agent.url, messageWithHint, {
680
706
  apiKey,
681
707
  userEmail: callerEmail,
@@ -692,6 +718,7 @@ export async function run(
692
718
  ...(callTimeoutMs
693
719
  ? {
694
720
  timeoutMs: callTimeoutMs,
721
+ ...(submissionTimeoutMs ? { submissionTimeoutMs } : {}),
695
722
  }
696
723
  : {}),
697
724
  });
@@ -21,16 +21,6 @@ import {
21
21
  import { useEffect, useState, type MouseEvent, type ReactNode } from "react";
22
22
  import { Link } from "react-router";
23
23
 
24
- import {
25
- AlertDialog,
26
- AlertDialogAction,
27
- AlertDialogCancel,
28
- AlertDialogContent,
29
- AlertDialogDescription,
30
- AlertDialogFooter,
31
- AlertDialogHeader,
32
- AlertDialogTitle,
33
- } from "@/components/ui/alert-dialog";
34
24
  import { Button } from "@/components/ui/button";
35
25
  import {
36
26
  Collapsible,
@@ -727,7 +717,6 @@ function DatabaseSidebarRow({
727
717
  };
728
718
  }) {
729
719
  const t = useT();
730
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
731
720
  const canEdit = item.document.canEdit !== false;
732
721
  const canManage =
733
722
  item.document.canManage === true ||
@@ -858,7 +847,7 @@ function DatabaseSidebarRow({
858
847
  {canManage && onDeleteItem ? (
859
848
  <DropdownMenuItem
860
849
  className="text-destructive focus:text-destructive"
861
- onSelect={() => setDeleteDialogOpen(true)}
850
+ onSelect={() => onDeleteItem(item)}
862
851
  >
863
852
  <IconTrash className="me-2 size-4" />
864
853
  {t("database.delete")}
@@ -903,28 +892,6 @@ function DatabaseSidebarRow({
903
892
  </div>
904
893
  )}
905
894
  </div>
906
-
907
- <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
908
- <AlertDialogContent>
909
- <AlertDialogHeader>
910
- <AlertDialogTitle>
911
- {t("sidebar.deletePageQuestion")}
912
- </AlertDialogTitle>
913
- <AlertDialogDescription>
914
- {t("sidebar.deletePageDescription", { title })}
915
- </AlertDialogDescription>
916
- </AlertDialogHeader>
917
- <AlertDialogFooter>
918
- <AlertDialogCancel>{t("comments.cancel")}</AlertDialogCancel>
919
- <AlertDialogAction
920
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
921
- onClick={() => onDeleteItem?.(item)}
922
- >
923
- {t("database.delete")}
924
- </AlertDialogAction>
925
- </AlertDialogFooter>
926
- </AlertDialogContent>
927
- </AlertDialog>
928
895
  </>
929
896
  );
930
897
  }
@@ -222,6 +222,18 @@ const SIDEBAR_SECTION_COLLAPSE_STORAGE_KEY =
222
222
  const TRASH_COLLAPSED_DEFAULT_MIGRATION_KEY =
223
223
  "content-sidebar-trash-collapsed-default-v2";
224
224
  const CONTENT_SIDEBAR_STATE_VERSION = 1 as const;
225
+
226
+ function afterBodyPointerUnlock(callback: () => void) {
227
+ const run = () => {
228
+ if (document.body.style.pointerEvents === "none") {
229
+ window.requestAnimationFrame(run);
230
+ return;
231
+ }
232
+ callback();
233
+ };
234
+ window.requestAnimationFrame(run);
235
+ }
236
+
225
237
  interface ContentSidebarStateSnapshot {
226
238
  version: typeof CONTENT_SIDEBAR_STATE_VERSION;
227
239
  expandedWorkspaceIds: string[];
@@ -906,6 +918,11 @@ export function DocumentSidebar({
906
918
  }, [setStoredCollapsedSections]);
907
919
  const [removeLocalFilesDialogOpen, setRemoveLocalFilesDialogOpen] =
908
920
  useState(false);
921
+ const [pendingDelete, setPendingDelete] = useState<{
922
+ id: string;
923
+ title: string;
924
+ } | null>(null);
925
+ const confirmedDeleteIdRef = useRef<string | null>(null);
909
926
  const settingsActive = location.pathname.startsWith("/settings");
910
927
  const sensors = useSensors(
911
928
  useSensor(PointerSensor, {
@@ -1258,6 +1275,12 @@ export function DocumentSidebar({
1258
1275
  ],
1259
1276
  );
1260
1277
 
1278
+ const requestDelete = useCallback((id: string, title: string) => {
1279
+ afterBodyPointerUnlock(() => {
1280
+ setPendingDelete({ id, title });
1281
+ });
1282
+ }, []);
1283
+
1261
1284
  const handleReorderPage = useCallback(
1262
1285
  async (id: string, overId: string) => {
1263
1286
  if (id === overId) return;
@@ -1520,7 +1543,7 @@ export function DocumentSidebar({
1520
1543
  }}
1521
1544
  onCreateChildPage={(parentId) => handleCreatePage(parentId)}
1522
1545
  onCreateChildDatabase={(parentId) => handleCreateDatabase(parentId)}
1523
- onDelete={handleDelete}
1546
+ onDelete={requestDelete}
1524
1547
  onToggleFavorite={handleToggleFavorite}
1525
1548
  />
1526
1549
  ))}
@@ -1832,7 +1855,12 @@ export function DocumentSidebar({
1832
1855
  onCreateChildDatabase={(nextSpace, item) =>
1833
1856
  void handleCreateDatabase(item.document.id, nextSpace.id)
1834
1857
  }
1835
- onDeleteItem={(item) => void handleDelete(item.document.id)}
1858
+ onDeleteItem={(item) =>
1859
+ requestDelete(
1860
+ item.document.id,
1861
+ item.document.title || t("sidebar.untitled"),
1862
+ )
1863
+ }
1836
1864
  onToggleFavorite={(item) =>
1837
1865
  handleToggleFavorite(item.document.id, !item.document.isFavorite)
1838
1866
  }
@@ -2348,7 +2376,10 @@ export function DocumentSidebar({
2348
2376
  void handleCreateDatabase(item.document.id)
2349
2377
  }
2350
2378
  onDeleteItem={(item) =>
2351
- void handleDelete(item.document.id)
2379
+ requestDelete(
2380
+ item.document.id,
2381
+ item.document.title || t("sidebar.untitled"),
2382
+ )
2352
2383
  }
2353
2384
  onToggleFavorite={(item) =>
2354
2385
  handleToggleFavorite(item.document.id, false)
@@ -2416,6 +2447,49 @@ export function DocumentSidebar({
2416
2447
  onMouseDown={handleMouseDown}
2417
2448
  />
2418
2449
  )}
2450
+ <AlertDialog
2451
+ open={pendingDelete !== null}
2452
+ onOpenChange={(open) => {
2453
+ if (open) return;
2454
+ setPendingDelete(null);
2455
+ const confirmedDeleteId = confirmedDeleteIdRef.current;
2456
+ confirmedDeleteIdRef.current = null;
2457
+ if (confirmedDeleteId) {
2458
+ afterBodyPointerUnlock(() => {
2459
+ void handleDelete(confirmedDeleteId);
2460
+ });
2461
+ }
2462
+ }}
2463
+ >
2464
+ <AlertDialogContent>
2465
+ <AlertDialogHeader>
2466
+ <AlertDialogTitle>
2467
+ {t("sidebar.deletePageQuestion")}
2468
+ </AlertDialogTitle>
2469
+ <AlertDialogDescription>
2470
+ {pendingDelete
2471
+ ? t("sidebar.deletePageDescription", {
2472
+ title: pendingDelete.title,
2473
+ })
2474
+ : null}
2475
+ </AlertDialogDescription>
2476
+ </AlertDialogHeader>
2477
+ <AlertDialogFooter>
2478
+ <AlertDialogCancel>{t("comments.cancel")}</AlertDialogCancel>
2479
+ <AlertDialogAction
2480
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
2481
+ disabled={
2482
+ deleteDocument.isPending || deleteContentDatabase.isPending
2483
+ }
2484
+ onClick={() => {
2485
+ confirmedDeleteIdRef.current = pendingDelete?.id ?? null;
2486
+ }}
2487
+ >
2488
+ {t("database.delete")}
2489
+ </AlertDialogAction>
2490
+ </AlertDialogFooter>
2491
+ </AlertDialogContent>
2492
+ </AlertDialog>
2419
2493
  <AlertDialog
2420
2494
  open={removeLocalFilesDialogOpen}
2421
2495
  onOpenChange={setRemoveLocalFilesDialogOpen}