@agent-native/core-corpus 0.209.2 → 0.209.3-nightly-20261016183441

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 (62) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  3. package/corpus/templates/analytics/actions/update-dashboard.ts +9 -0
  4. package/corpus/templates/analytics/app/components/auth/SqlQuerySnapshotSessionBridge.tsx +25 -0
  5. package/corpus/templates/analytics/app/components/dashboard/PanelFreshnessClock.tsx +140 -0
  6. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +104 -48
  7. package/corpus/templates/analytics/app/components/dashboard/panel-freshness.ts +171 -0
  8. package/corpus/templates/analytics/app/global.css +59 -0
  9. package/corpus/templates/analytics/app/i18n/en-US.ts +8 -0
  10. package/corpus/templates/analytics/app/lib/cost-tracker.ts +122 -16
  11. package/corpus/templates/analytics/app/lib/query-metrics.ts +50 -13
  12. package/corpus/templates/analytics/app/lib/sql-query-cache.ts +697 -0
  13. package/corpus/templates/analytics/app/lib/sql-query-freshness.ts +85 -0
  14. package/corpus/templates/analytics/app/lib/sql-query-snapshot-session.ts +145 -0
  15. package/corpus/templates/analytics/app/lib/sql-query.ts +366 -36
  16. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +73 -18
  17. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/filter-vars.ts +45 -12
  18. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +5 -0
  19. package/corpus/templates/analytics/app/root.tsx +2 -0
  20. package/corpus/templates/analytics/changelog/2026-10-14-dashboard-charts-now-reappear-instantly-after-a-reload-from-.md +6 -0
  21. package/corpus/templates/analytics/changelog/2026-10-14-dashboard-panels-show-fetch-time-in-a-clock-and-label-only-st.md +6 -0
  22. package/corpus/templates/analytics/changelog/2026-10-15-dashboard-query-costs-and-refresh-confirmations-reflect-actu.md +6 -0
  23. package/corpus/templates/analytics/changelog/2026-10-15-dashboard-results-disappear-when-access-is-withdrawn-and-exp.md +6 -0
  24. package/corpus/templates/analytics/changelog/2026-10-16-dashboard-freshness-stays-in-the-title-row-with-compact-upda.md +6 -0
  25. package/corpus/templates/analytics/changelog/2026-10-16-dashboard-update-status-remains-visible-for-sources-without-.md +6 -0
  26. package/corpus/templates/analytics/server/lib/bigquery.ts +35 -4
  27. package/corpus/templates/analytics/server/lib/dashboard-panel-query.ts +2 -3
  28. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +9 -6
  29. package/corpus/templates/analytics/server/lib/first-party-analytics-cache.ts +2 -2
  30. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +4 -2
  31. package/corpus/templates/analytics/shared/sql-query-contract.ts +49 -0
  32. package/corpus/templates/assets/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  33. package/corpus/templates/brain/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  34. package/corpus/templates/calendar/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  35. package/corpus/templates/chat/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  36. package/corpus/templates/clips/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  37. package/corpus/templates/content/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  38. package/corpus/templates/crm/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  39. package/corpus/templates/design/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  40. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +24 -0
  41. package/corpus/templates/design/app/components/design/types.ts +1 -0
  42. package/corpus/templates/design/app/lib/design-import.ts +35 -0
  43. package/corpus/templates/design/app/pages/design-editor/clone-and-pen-edit.ts +11 -0
  44. package/corpus/templates/design/app/pages/design-editor/command-types.ts +2 -0
  45. package/corpus/templates/design/app/pages/design-editor/commands/copy-selection.ts +3 -0
  46. package/corpus/templates/design/app/pages/design-editor/commands/get-selected-layer-snapshots.ts +19 -0
  47. package/corpus/templates/design/app/pages/design-editor/commands/paste-selection.ts +118 -20
  48. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-layer-actions.ts +15 -3
  49. package/corpus/templates/design/app/pages/design-editor/relative-position-offset.ts +109 -0
  50. package/corpus/templates/design/changelog/2026-10-15-dragged-layers-use-the-final-release-position.md +6 -0
  51. package/corpus/templates/design/changelog/2026-10-15-repeated-pastes-preserve-relative-layer-offsets.md +6 -0
  52. package/corpus/templates/design/changelog/2026-10-15-text-wrapping-hug-widths.md +6 -0
  53. package/corpus/templates/design/shared/code-layer.ts +2 -1
  54. package/corpus/templates/design/shared/relative-position-containing-block.ts +40 -0
  55. package/corpus/templates/dispatch/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  56. package/corpus/templates/forms/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  57. package/corpus/templates/mail/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  58. package/corpus/templates/plan/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  59. package/corpus/templates/slides/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  60. package/corpus/templates/slides/app/components/editor/NewDeckReferenceStep.tsx +137 -118
  61. package/corpus/templates/slides/changelog/2026-10-16-search-and-preview-reference-decks-before-choosing-one.md +6 -0
  62. package/package.json +1 -1
@@ -1,5 +1,12 @@
1
1
  import { callAction, useSession } from "@agent-native/core/client/hooks";
2
2
  import { DASHBOARD_REPORT_ACTION_TIMEOUT_MS } from "@shared/dashboard-report-timeouts";
3
+ import {
4
+ isConfirmedPreQueryFailure,
5
+ isSqlQueryAccessDenied,
6
+ SqlQueryRefusalError,
7
+ supportsSqlQueryCacheBypass,
8
+ type SqlQueryExecutionMetadata,
9
+ } from "@shared/sql-query-contract";
3
10
  import {
4
11
  MAX_CONCURRENT_FIRST_PARTY_SQL_QUERIES,
5
12
  MAX_CONCURRENT_SQL_QUERIES,
@@ -9,15 +16,27 @@ import { useEffect, useRef } from "react";
9
16
 
10
17
  import type { DataSourceType } from "@/pages/adhoc/sql-dashboard/types";
11
18
 
12
- import { addBytesProcessed } from "./cost-tracker";
19
+ import { recordQueryCost } from "./cost-tracker";
13
20
  import {
14
21
  DASHBOARD_SESSION_LOADING_SCOPE,
15
22
  dashboardCacheScope,
16
23
  } from "./prefetch-keys";
24
+ import {
25
+ describeSqlQueryFreshness,
26
+ type SqlQueryFreshness,
27
+ } from "./sql-query-freshness";
28
+ import {
29
+ readSqlQuerySnapshotForSession,
30
+ removeSqlQuerySnapshotForSession,
31
+ writeSqlQuerySnapshotForSession,
32
+ } from "./sql-query-snapshot-session";
17
33
 
18
34
  export { DASHBOARD_REPORT_ACTION_TIMEOUT_MS };
19
35
 
20
- export interface SqlQueryResult {
36
+ export interface SqlQueryResult extends Omit<
37
+ SqlQueryExecutionMetadata,
38
+ "bytesProcessed"
39
+ > {
21
40
  rows: Record<string, unknown>[];
22
41
  error?: string;
23
42
  schema?: { name: string; type: string }[];
@@ -139,54 +158,180 @@ type DashboardPanelQueryResponse = SqlQueryResult & {
139
158
  message?: string;
140
159
  };
141
160
 
161
+ type SqlQueryTimingStatus = "success" | "error" | "cancelled" | "timeout";
162
+
163
+ /** Sources whose queries scan BigQuery, so a lost request may still bill. */
164
+ function scansBigQuery(source: DataSourceType): boolean {
165
+ return source === "bigquery" || source === "first-party";
166
+ }
167
+
168
+ /**
169
+ * One line per query with durations and outcome only. No SQL, rows, account
170
+ * or credentials, so it is safe to leave on in production.
171
+ */
172
+ function logSqlQueryTiming(timing: {
173
+ status: SqlQueryTimingStatus;
174
+ source: DataSourceType;
175
+ cacheType: "none" | "app" | "provider" | "unknown";
176
+ forceRefresh: boolean;
177
+ queueMs: number;
178
+ requestMs: number;
179
+ totalMs: number;
180
+ }): void {
181
+ console.info("[sql-query] timing", {
182
+ ...timing,
183
+ queueMs: Math.round(timing.queueMs),
184
+ requestMs: Math.round(timing.requestMs),
185
+ totalMs: Math.round(timing.totalMs),
186
+ });
187
+ }
188
+
189
+ function responseMetadata(data: DashboardPanelQueryResponse): {
190
+ cached?: boolean;
191
+ providerCacheHit?: boolean;
192
+ truncated?: boolean;
193
+ fetchedAt?: string;
194
+ } {
195
+ const metadata: ReturnType<typeof responseMetadata> = {};
196
+ if (typeof data.cached === "boolean") metadata.cached = data.cached;
197
+ if (typeof data.providerCacheHit === "boolean") {
198
+ metadata.providerCacheHit = data.providerCacheHit;
199
+ }
200
+ if (typeof data.truncated === "boolean") metadata.truncated = data.truncated;
201
+ if (data.fetchedAt !== undefined) {
202
+ if (
203
+ typeof data.fetchedAt === "string" &&
204
+ Number.isFinite(Date.parse(data.fetchedAt))
205
+ ) {
206
+ metadata.fetchedAt = data.fetchedAt;
207
+ } else {
208
+ // Left absent so the panel says the time is unknown rather than guessing.
209
+ console.warn("[sql-query] ignored an unreadable fetchedAt");
210
+ }
211
+ }
212
+ return metadata;
213
+ }
214
+
142
215
  export async function executeSqlQuery(
143
216
  sql: string,
144
217
  source: DataSourceType,
145
218
  signal?: AbortSignal,
146
219
  options?: { reportScreenshot?: boolean; forceRefresh?: boolean },
147
220
  ): Promise<SqlQueryResult> {
221
+ const startedAt = performance.now();
222
+ const forceRefresh = Boolean(
223
+ options?.forceRefresh && supportsSqlQueryCacheBypass(source),
224
+ );
148
225
  const deadline = createDeadlineSignal(
149
226
  signal,
150
227
  DASHBOARD_REPORT_ACTION_TIMEOUT_MS,
151
228
  );
152
229
  let release: (() => void) | undefined;
153
230
  let data: DashboardPanelQueryResponse;
231
+ let dispatchedAt: number | undefined;
232
+ let queueMs = 0;
233
+ let requestMs = 0;
234
+ let status: SqlQueryTimingStatus = "success";
235
+ let cacheType: "none" | "app" | "provider" | "unknown" = "unknown";
154
236
  try {
155
237
  release = await acquireSqlQuerySlot(source, deadline.signal);
238
+ dispatchedAt = performance.now();
239
+ queueMs = dispatchedAt - startedAt;
156
240
  data = await callAction<DashboardPanelQueryResponse>(
157
241
  "query-dashboard-panel",
158
242
  {
159
243
  query: sql,
160
244
  source,
161
- ...(options?.forceRefresh && source === "bigquery"
162
- ? { forceRefresh: true }
163
- : {}),
245
+ ...(forceRefresh ? { forceRefresh: true } : {}),
164
246
  },
165
247
  {
166
248
  signal: deadline.signal,
167
249
  timeoutMs: DASHBOARD_REPORT_ACTION_TIMEOUT_MS,
168
250
  },
169
251
  );
252
+ requestMs = performance.now() - dispatchedAt;
253
+ } catch (error) {
254
+ const now = performance.now();
255
+ if (dispatchedAt === undefined) queueMs = now - startedAt;
256
+ else requestMs = now - dispatchedAt;
257
+ status = signal?.aborted
258
+ ? "cancelled"
259
+ : deadline.signal.aborted
260
+ ? "timeout"
261
+ : "error";
262
+ // A query still waiting for a slot never reached the warehouse. One that
263
+ // was sent may have scanned before it failed, so its cost is unknown.
264
+ if (
265
+ dispatchedAt !== undefined &&
266
+ scansBigQuery(source) &&
267
+ !isConfirmedPreQueryFailure(error)
268
+ ) {
269
+ recordQueryCost({
270
+ status: status === "cancelled" ? "cancelled" : "error",
271
+ });
272
+ }
273
+ logSqlQueryTiming({
274
+ status,
275
+ source,
276
+ cacheType,
277
+ forceRefresh,
278
+ queueMs,
279
+ requestMs,
280
+ totalMs: now - startedAt,
281
+ });
282
+ throw error;
170
283
  } finally {
171
284
  release?.();
172
285
  deadline.cleanup();
173
286
  }
174
287
 
175
288
  if (typeof data?.error === "string") {
176
- throw new Error(
177
- typeof data.message === "string" && data.message
178
- ? data.message
179
- : data.error,
180
- );
289
+ // A refusal in the body (missing key, unsupported backend) ran no scan.
290
+ logSqlQueryTiming({
291
+ status: "error",
292
+ source,
293
+ cacheType,
294
+ forceRefresh,
295
+ queueMs,
296
+ requestMs,
297
+ totalMs: performance.now() - startedAt,
298
+ });
299
+ throw new SqlQueryRefusalError({
300
+ error: data.error,
301
+ message: data.message,
302
+ });
181
303
  }
182
304
 
183
- if (data.bytesProcessed) {
184
- addBytesProcessed(data.bytesProcessed);
185
- }
305
+ const metadata = responseMetadata(data);
306
+ recordQueryCost({
307
+ status: "success",
308
+ cached: metadata.cached,
309
+ providerCacheHit: metadata.providerCacheHit,
310
+ bytesProcessed: data.bytesProcessed,
311
+ });
312
+ // "none" is a claim that both layers missed; a response that does not say
313
+ // so for each layer is unknown, never a miss.
314
+ cacheType = metadata.cached
315
+ ? "app"
316
+ : metadata.providerCacheHit
317
+ ? "provider"
318
+ : metadata.cached === false && metadata.providerCacheHit === false
319
+ ? "none"
320
+ : "unknown";
321
+ logSqlQueryTiming({
322
+ status,
323
+ source,
324
+ cacheType,
325
+ forceRefresh,
326
+ queueMs,
327
+ requestMs,
328
+ totalMs: performance.now() - startedAt,
329
+ });
186
330
 
187
331
  return {
188
332
  rows: data.rows ?? [],
189
333
  schema: data.schema,
334
+ ...metadata,
190
335
  };
191
336
  }
192
337
 
@@ -223,6 +368,13 @@ export function useSqlQuery(
223
368
  * explicit `refreshToken` refresh does. Ignored when `staleTime` is set.
224
369
  */
225
370
  reuseCompletedOnEnable?: boolean;
371
+ /**
372
+ * Saves each successful result in this tab's session storage and, after a
373
+ * reload, shows the saved copy for this exact account, SQL and source while
374
+ * the query's one normal fetch runs. The copy keeps its original fetch
375
+ * time and is marked `restored` in `freshness`; it is never fresh data.
376
+ */
377
+ snapshots?: boolean;
226
378
  },
227
379
  ) {
228
380
  // The signed-in user and org are part of the cache key, so one account's
@@ -244,30 +396,124 @@ export function useSqlQuery(
244
396
  accountScope: string;
245
397
  retainScope: string;
246
398
  } | null>(null);
247
- const successfulRefreshToken = useRef(0);
248
399
  const refreshToken = options?.refreshToken ?? 0;
249
400
  const wantsQuery = options?.enabled ?? true;
250
401
  const enabled = wantsQuery && sessionReady;
251
402
  const latestRefreshToken = useRef(refreshToken);
252
403
  const latestStartedRefreshToken = useRef(refreshToken);
253
404
  latestRefreshToken.current = refreshToken;
405
+ // A manual refresh belongs to the query it was requested on. Sources that
406
+ // support cache bypass keep it pending until success. It is recorded while
407
+ // rendering because the fetch it triggers can start before any effect runs.
408
+ const pendingManualRefresh = useRef<{
409
+ token: number;
410
+ queryKeyHash: string;
411
+ failed?: boolean;
412
+ } | null>(null);
413
+ // The manual request whose own forced fetch last completed, for the caller
414
+ // that shows it. A fetch that started without that request (an automatic
415
+ // revalidation, a cancelled or failed attempt) never writes it, and it is
416
+ // dropped as soon as the account or query it belongs to changes.
417
+ const completedManualRefresh = useRef<{
418
+ token: number;
419
+ queryKeyHash: string;
420
+ completedAt: number;
421
+ } | null>(null);
422
+ if (
423
+ completedManualRefresh.current &&
424
+ completedManualRefresh.current.queryKeyHash !== queryKeyHash
425
+ ) {
426
+ completedManualRefresh.current = null;
427
+ }
428
+ const observedRefreshToken = useRef(refreshToken);
429
+ if (refreshToken > observedRefreshToken.current) {
430
+ observedRefreshToken.current = refreshToken;
431
+ pendingManualRefresh.current = { token: refreshToken, queryKeyHash };
432
+ } else if (
433
+ pendingManualRefresh.current &&
434
+ pendingManualRefresh.current.queryKeyHash !== queryKeyHash
435
+ ) {
436
+ // The request failed or was superseded, and the panel has moved to
437
+ // another query: that one is an ordinary automatic load.
438
+ pendingManualRefresh.current = null;
439
+ }
254
440
  const previousRefreshToken = useRef(refreshToken);
255
441
  const previousEnabled = useRef(enabled);
442
+ const snapshotKey = JSON.stringify([queryKey, sql, source]);
443
+ const restoredCopy = useRef<{
444
+ id: string;
445
+ result: SqlQueryResult | null;
446
+ } | null>(null);
256
447
  const queryClient = useQueryClient();
257
448
  const query = useQuery<SqlQueryResult>({
258
449
  queryKey: scopedQueryKey,
259
450
  queryFn: async ({ signal }) => {
260
- const currentRefreshToken = latestRefreshToken.current;
261
- latestStartedRefreshToken.current = currentRefreshToken;
262
- const forceRefresh = currentRefreshToken > successfulRefreshToken.current;
263
- const result = await executeSqlQuery(sql, source, signal, {
264
- reportScreenshot: options?.reportScreenshot,
265
- forceRefresh,
266
- });
267
- successfulRefreshToken.current = Math.max(
268
- successfulRefreshToken.current,
269
- currentRefreshToken,
270
- );
451
+ latestStartedRefreshToken.current = latestRefreshToken.current;
452
+ const manual = pendingManualRefresh.current;
453
+ const manualRequested = manual?.queryKeyHash === queryKeyHash;
454
+ const forceRefresh =
455
+ manualRequested && supportsSqlQueryCacheBypass(source);
456
+ let result: SqlQueryResult;
457
+ try {
458
+ result = await executeSqlQuery(sql, source, signal, {
459
+ reportScreenshot: options?.reportScreenshot,
460
+ forceRefresh,
461
+ });
462
+ } catch (error) {
463
+ if (isSqlQueryAccessDenied(error)) {
464
+ if (
465
+ lastShown.current?.accountScope === accountScope &&
466
+ lastShown.current?.retainScope === retainScope
467
+ ) {
468
+ lastShown.current = null;
469
+ }
470
+ restoredCopy.current = null;
471
+ removeSqlQuerySnapshotForSession({
472
+ accountScope,
473
+ queryKey: snapshotKey,
474
+ });
475
+ // React Query retains data on failed revalidation by default. Remove
476
+ // just this denied result while letting it commit the real error.
477
+ queryClient
478
+ .getQueryCache()
479
+ .find({ queryKey: scopedQueryKey, exact: true })
480
+ ?.setState({
481
+ data: undefined,
482
+ dataUpdatedAt: 0,
483
+ error,
484
+ status: "error",
485
+ });
486
+ }
487
+ // The request stays pending for the cache bypass, but a later success
488
+ // is not the one the user asked for.
489
+ if (manual && forceRefresh && !signal.aborted) manual.failed = true;
490
+ if (
491
+ manualRequested &&
492
+ !forceRefresh &&
493
+ pendingManualRefresh.current === manual
494
+ ) {
495
+ pendingManualRefresh.current = null;
496
+ }
497
+ throw error;
498
+ }
499
+ if (manualRequested && pendingManualRefresh.current === manual) {
500
+ pendingManualRefresh.current = null;
501
+ }
502
+ if (manual && forceRefresh && !manual.failed && !signal.aborted) {
503
+ completedManualRefresh.current = {
504
+ token: manual.token,
505
+ queryKeyHash,
506
+ completedAt: Date.now(),
507
+ };
508
+ }
509
+ if (options?.snapshots) {
510
+ writeSqlQuerySnapshotForSession({
511
+ accountScope,
512
+ queryKey: snapshotKey,
513
+ result,
514
+ signal,
515
+ });
516
+ }
271
517
  return result;
272
518
  },
273
519
  enabled,
@@ -275,15 +521,22 @@ export function useSqlQuery(
275
521
  refetchOnMount: options?.refetchOnMount ?? false,
276
522
  refetchOnReconnect: options?.refetchOnReconnect ?? false,
277
523
  refetchOnWindowFocus: options?.refetchOnWindowFocus ?? false,
278
- retry: options?.retry ?? false,
524
+ retry: (failureCount, error) => {
525
+ if (isSqlQueryAccessDenied(error)) return false;
526
+ const retry = options?.retry ?? false;
527
+ return typeof retry === "number" ? failureCount < retry : retry;
528
+ },
279
529
  staleTime:
280
530
  options?.staleTime ??
281
531
  (options?.reuseCompletedOnEnable ? Infinity : 5 * 60 * 1000),
282
532
  });
283
533
  const refetch = query.refetch;
534
+ const accessDenied = isSqlQueryAccessDenied(query.error);
284
535
 
285
536
  useEffect(() => {
286
- if (query.data && sessionReady && retainScope !== undefined) {
537
+ if (accessDenied) {
538
+ lastShown.current = null;
539
+ } else if (query.data && sessionReady && retainScope !== undefined) {
287
540
  lastShown.current = {
288
541
  data: query.data,
289
542
  queryKeyHash,
@@ -296,7 +549,14 @@ export function useSqlQuery(
296
549
  ) {
297
550
  lastShown.current = null;
298
551
  }
299
- }, [query.data, sessionReady, retainScope, queryKeyHash, accountScope]);
552
+ }, [
553
+ query.data,
554
+ sessionReady,
555
+ retainScope,
556
+ queryKeyHash,
557
+ accountScope,
558
+ accessDenied,
559
+ ]);
300
560
 
301
561
  useEffect(() => {
302
562
  const wasEnabled = previousEnabled.current;
@@ -304,15 +564,15 @@ export function useSqlQuery(
304
564
  const refreshTokenChanged = refreshToken > previousRefreshToken.current;
305
565
  previousRefreshToken.current = refreshToken;
306
566
  if (!enabled) return;
307
- const enablingPendingRefresh =
308
- !wasEnabled && refreshToken > successfulRefreshToken.current;
309
- if (
310
- (!refreshTokenChanged && !enablingPendingRefresh) ||
311
- refreshToken <= successfulRefreshToken.current
312
- ) {
567
+ const pending = pendingManualRefresh.current;
568
+ const enablingPendingRefresh = !wasEnabled && pending !== null;
569
+ if (!pending || (!refreshTokenChanged && !enablingPendingRefresh)) {
313
570
  return;
314
571
  }
315
- if (query.isFetching && latestStartedRefreshToken.current >= refreshToken) {
572
+ if (
573
+ query.isFetching &&
574
+ latestStartedRefreshToken.current >= pending.token
575
+ ) {
316
576
  return;
317
577
  }
318
578
  void queryClient
@@ -327,12 +587,76 @@ export function useSqlQuery(
327
587
  refreshToken,
328
588
  ]);
329
589
 
590
+ // The saved copy is read once per account and query, and only for a query
591
+ // with nothing in memory. Once the query has data the copy is spent, so a
592
+ // later cache eviction cannot bring back rows older than ones already shown.
593
+ const restoreId = `${accountScope}
594
+ ${snapshotKey}`;
595
+ let restored: SqlQueryResult | null = null;
596
+ if (accessDenied) {
597
+ restoredCopy.current = { id: restoreId, result: null };
598
+ } else if (options?.snapshots && sessionReady && query.data === undefined) {
599
+ if (restoredCopy.current?.id !== restoreId) {
600
+ const read = readSqlQuerySnapshotForSession({
601
+ accountScope,
602
+ queryKey: snapshotKey,
603
+ });
604
+ restoredCopy.current = {
605
+ id: restoreId,
606
+ result: read.status === "hit" ? read.snapshot.result : null,
607
+ };
608
+ }
609
+ restored = restoredCopy.current.result;
610
+ } else if (query.data !== undefined && restoredCopy.current) {
611
+ restoredCopy.current = { id: restoreId, result: null };
612
+ }
613
+
614
+ const freshnessOf = (
615
+ data: SqlQueryResult | undefined,
616
+ isRestored: boolean,
617
+ ): SqlQueryFreshness | undefined =>
618
+ describeSqlQueryFreshness(data, { source, restored: isRestored });
619
+ // Offered only once the data it produced has been committed to the query.
620
+ const completion = completedManualRefresh.current;
621
+ const completedManualRefreshToken =
622
+ completion &&
623
+ completion.queryKeyHash === queryKeyHash &&
624
+ query.data !== undefined &&
625
+ query.dataUpdatedAt >= completion.completedAt
626
+ ? completion.token
627
+ : undefined;
330
628
  if (!sessionReady && wantsQuery) {
331
629
  return {
332
630
  ...query,
333
631
  data: undefined,
334
632
  isPlaceholderData: false,
335
633
  isLoading: true,
634
+ freshness: undefined,
635
+ completedManualRefreshToken: undefined,
636
+ };
637
+ }
638
+ if (accessDenied) {
639
+ return {
640
+ ...query,
641
+ data: undefined,
642
+ isPlaceholderData: false,
643
+ isLoading: false,
644
+ freshness: undefined,
645
+ completedManualRefreshToken: undefined,
646
+ };
647
+ }
648
+ if (
649
+ restored &&
650
+ query.data === undefined &&
651
+ (enabled || (options?.retainWhilePaused && sessionReady))
652
+ ) {
653
+ return {
654
+ ...query,
655
+ data: restored,
656
+ isPlaceholderData: false,
657
+ isLoading: false,
658
+ freshness: freshnessOf(restored, true),
659
+ completedManualRefreshToken: undefined,
336
660
  };
337
661
  }
338
662
  const held = lastShown.current;
@@ -348,7 +672,13 @@ export function useSqlQuery(
348
672
  data: held.data,
349
673
  isPlaceholderData: held.queryKeyHash !== queryKeyHash,
350
674
  isLoading: false,
675
+ freshness: freshnessOf(held.data, false),
676
+ completedManualRefreshToken: undefined,
351
677
  };
352
678
  }
353
- return query;
679
+ return {
680
+ ...query,
681
+ freshness: freshnessOf(query.data, false),
682
+ completedManualRefreshToken,
683
+ };
354
684
  }
@@ -34,6 +34,8 @@ import {
34
34
  DataNoteLines,
35
35
  UnplacedDataNotesListener,
36
36
  } from "@/components/dashboard/data-note-bands";
37
+ import type { PanelFreshness } from "@/components/dashboard/panel-freshness";
38
+ import { PanelFreshnessClock } from "@/components/dashboard/PanelFreshnessClock";
37
39
  import { ChartFillHeight, SqlChart } from "@/components/dashboard/SqlChart";
38
40
  import {
39
41
  AlertDialog,
@@ -87,6 +89,29 @@ import { ViewSqlPopover } from "./ViewSqlPopover";
87
89
 
88
90
  const INHERIT_TIME_RANGE = "__inherit__";
89
91
 
92
+ function PanelTitle({ title }: { title: string }) {
93
+ const [open, setOpen] = useState(false);
94
+ return (
95
+ <CardTitle className="min-w-0 text-sm font-medium">
96
+ <Tooltip open={open} onOpenChange={setOpen}>
97
+ <TooltipTrigger asChild>
98
+ <button
99
+ type="button"
100
+ onClick={(event) => {
101
+ event.preventDefault();
102
+ setOpen(true);
103
+ }}
104
+ className="block max-w-full truncate rounded text-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
105
+ >
106
+ {title}
107
+ </button>
108
+ </TooltipTrigger>
109
+ <TooltipContent>{title}</TooltipContent>
110
+ </Tooltip>
111
+ </CardTitle>
112
+ );
113
+ }
114
+
90
115
  export function dashboardTileElementId(panelId: string): string {
91
116
  return `dashboard-tile-${panelId}`;
92
117
  }
@@ -275,6 +300,28 @@ export function SqlChartCard({
275
300
  setExportCsv(handler ? () => handler : null);
276
301
  }, []);
277
302
 
303
+ // Keyed by the panel it was reported for, so a card reused for another
304
+ // panel or dashboard never shows the previous one's clock.
305
+ const panelScope = JSON.stringify([
306
+ accountScope,
307
+ dashboardId ?? null,
308
+ panel.id,
309
+ ]);
310
+ const [reportedFreshness, setReportedFreshness] = useState<{
311
+ scope: string;
312
+ freshness: PanelFreshness | null;
313
+ } | null>(null);
314
+ const handleFreshnessChange = useCallback(
315
+ (freshness: PanelFreshness | null) => {
316
+ setReportedFreshness({ scope: panelScope, freshness });
317
+ },
318
+ [panelScope],
319
+ );
320
+ const panelFreshness =
321
+ reportedFreshness?.scope === panelScope
322
+ ? reportedFreshness.freshness
323
+ : null;
324
+
278
325
  const handleCopyTableChange = useCallback(
279
326
  (handler: (() => Promise<void>) | null) => {
280
327
  setCopyTable(handler ? () => handler : null);
@@ -716,26 +763,33 @@ export function SqlChartCard({
716
763
  highlighted && "ring-2 ring-primary",
717
764
  )}
718
765
  >
719
- <CardHeader className="pb-2 flex flex-row items-center gap-2 shrink-0">
720
- <div className="flex min-w-0 flex-1 items-center gap-1">
721
- <CardTitle className="text-sm font-medium truncate">
722
- {panel.title}
723
- </CardTitle>
766
+ <CardHeader className="dashboard-panel-header grid space-y-0 shrink-0 pb-2">
767
+ <div className="dashboard-panel-title flex min-w-0 items-center gap-1">
768
+ <PanelTitle title={panel.title} />
724
769
  <DataNoteFlag notes={flaggedDataNotes} panelId={panel.id} />
770
+ {timeRangeOverrideLabel ? (
771
+ <Badge
772
+ variant="secondary"
773
+ className="max-w-20 min-w-0 shrink truncate px-1.5 py-0 text-[10px] font-normal"
774
+ title={timeRangeOverrideLabel}
775
+ >
776
+ <span className="sr-only">
777
+ {t("sqlDashboard.chartTimeRangeOverride")}{" "}
778
+ </span>
779
+ {timeRangeOverrideLabel}
780
+ </Badge>
781
+ ) : null}
725
782
  </div>
726
- {timeRangeOverrideLabel ? (
727
- <Badge
728
- variant="secondary"
729
- className="max-w-32 shrink-0 truncate px-1.5 py-0 text-[10px] font-normal"
730
- title={timeRangeOverrideLabel}
731
- >
732
- <span className="sr-only">
733
- {t("sqlDashboard.chartTimeRangeOverride")}{" "}
734
- </span>
735
- {timeRangeOverrideLabel}
736
- </Badge>
737
- ) : null}
738
- <div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
783
+ <div className="dashboard-panel-freshness min-w-0 overflow-hidden">
784
+ {panelFreshness ? (
785
+ <PanelFreshnessClock
786
+ freshness={panelFreshness}
787
+ showAge
788
+ className="h-6 w-full px-1"
789
+ />
790
+ ) : null}
791
+ </div>
792
+ <div className="dashboard-panel-actions flex items-center justify-end gap-1 opacity-0 transition-opacity motion-reduce:transition-none group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">
739
793
  {!editable || onSaveSql ? (
740
794
  <ViewSqlPopover
741
795
  panel={panel}
@@ -926,6 +980,7 @@ export function SqlChartCard({
926
980
  onRefreshRequested={handleRefresh}
927
981
  dashboardId={dashboardId}
928
982
  onExportCsvChange={handleExportCsvChange}
983
+ onFreshnessChange={handleFreshnessChange}
929
984
  onCopyTableChange={handleCopyTableChange}
930
985
  {...tableColumnProps}
931
986
  extensionContext={extensionContext}