@agent-native/core 0.114.13 → 0.114.16

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 (163) hide show
  1. package/bin/agent-native.js +35 -14
  2. package/bin/launcher.js +34 -0
  3. package/corpus/README.md +2 -2
  4. package/corpus/core/CHANGELOG.md +21 -0
  5. package/corpus/core/bin/agent-native.js +35 -14
  6. package/corpus/core/bin/launcher.js +34 -0
  7. package/corpus/core/docs/content/deployment.mdx +1 -1
  8. package/corpus/core/docs/content/frames.mdx +1 -1
  9. package/corpus/core/docs/content/integrations.mdx +27 -27
  10. package/corpus/core/docs/content/pr-visual-recap.mdx +17 -13
  11. package/corpus/core/docs/content/workspace-management.mdx +1 -1
  12. package/corpus/core/package.json +2 -1
  13. package/corpus/core/src/agent/engine/builder-engine.ts +4 -0
  14. package/corpus/core/src/client/AgentPanel.tsx +10 -4
  15. package/corpus/core/src/client/ConnectBuilderCard.tsx +5 -1
  16. package/corpus/core/src/client/chat/markdown-renderer.tsx +3 -5
  17. package/corpus/core/src/client/error-format.ts +2 -1
  18. package/corpus/core/src/client/index.ts +1 -0
  19. package/corpus/core/src/client/integrations/IntegrationsPanel.tsx +2 -18
  20. package/corpus/core/src/client/resources/McpIntegrationDialog.tsx +43 -32
  21. package/corpus/core/src/client/resources/mcp-integration-catalog.ts +4 -2
  22. package/corpus/core/src/client/settings/BackgroundAgentSection.tsx +5 -1
  23. package/corpus/core/src/client/settings/SettingsPanel.tsx +4 -4
  24. package/corpus/core/src/client/settings/useBuilderStatus.ts +3 -0
  25. package/corpus/core/src/localization/default-messages.ts +1 -7
  26. package/corpus/core/src/server/builder-browser.ts +4 -0
  27. package/corpus/core/src/server/builder-design-systems.ts +6 -1
  28. package/corpus/core/src/server/fusion-app.ts +5 -1
  29. package/corpus/core/src/shared/builder-link-tracking.ts +41 -0
  30. package/corpus/core/src/shared/index.ts +1 -0
  31. package/corpus/templates/analytics/AGENTS.md +10 -1
  32. package/corpus/templates/analytics/actions/save-dashboard-report-subscription.ts +10 -2
  33. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +5 -4
  34. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +20 -32
  35. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/report-panel-window.ts +62 -0
  36. package/corpus/templates/analytics/changelog/2026-07-21-chart-series-controls-stay-open-while-moving-from-the-legend.md +6 -0
  37. package/corpus/templates/analytics/changelog/2026-07-21-dashboard-email-captures-complete-large-dashboards-in-chunks.md +6 -0
  38. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +4 -4
  39. package/corpus/templates/analytics/server/jobs/dashboard-report.ts +1 -8
  40. package/corpus/templates/analytics/server/lib/dashboard-report-subscriptions.ts +39 -7
  41. package/corpus/templates/analytics/server/lib/dashboard-report.ts +341 -251
  42. package/corpus/templates/analytics/server/lib/first-party-dashboard-repair.ts +114 -0
  43. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +84 -6
  44. package/corpus/templates/analytics/server/plugins/db.ts +13 -0
  45. package/corpus/templates/clips/app/components/library/bulk-action-toolbar.tsx +25 -8
  46. package/corpus/templates/clips/app/components/library/library-grid.tsx +118 -9
  47. package/corpus/templates/clips/app/components/library/library-layout.tsx +1 -1
  48. package/corpus/templates/clips/app/components/library/recording-card.tsx +10 -8
  49. package/corpus/templates/clips/app/components/player/video-player.tsx +10 -4
  50. package/corpus/templates/clips/app/i18n/en-US.ts +6 -0
  51. package/corpus/templates/clips/app/routes/_app.trash.tsx +16 -1
  52. package/corpus/templates/clips/changelog/2026-07-21-choose-which-whisper-model-to-use-in-settings.md +6 -0
  53. package/corpus/templates/clips/changelog/2026-07-21-fixed-organization-recording-visibility-default.md +6 -0
  54. package/corpus/templates/clips/changelog/2026-07-21-meeting-notes-stop-when-calls-end.md +6 -0
  55. package/corpus/templates/clips/desktop/src/app.tsx +90 -78
  56. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +2 -0
  57. package/corpus/templates/clips/desktop/src/hooks/useWhisperSettings.ts +172 -0
  58. package/corpus/templates/clips/desktop/src/lib/meeting-call-app.ts +41 -0
  59. package/corpus/templates/clips/desktop/src/lib/silence-events.ts +1 -0
  60. package/corpus/templates/clips/desktop/src/shared/config.ts +1 -0
  61. package/corpus/templates/clips/desktop/src/styles.css +80 -7
  62. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +32 -0
  63. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
  64. package/corpus/templates/clips/desktop/src-tauri/src/silence_detector.rs +268 -20
  65. package/corpus/templates/clips/desktop/src-tauri/src/whisper_model.rs +285 -164
  66. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +18 -7
  67. package/corpus/templates/clips/learnings.defaults.md +1 -1
  68. package/corpus/templates/clips/server/plugins/db.ts +11 -0
  69. package/corpus/templates/clips/shared/builder-credits.ts +1 -1
  70. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +20 -38
  71. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +40 -1
  72. package/corpus/templates/design/app/components/design/FusionAppBanner.tsx +9 -1
  73. package/corpus/templates/design/app/components/design/edit-panel/component-section.tsx +5 -1
  74. package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +9 -1
  75. package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +9 -1
  76. package/dist/agent/engine/builder-engine.d.ts.map +1 -1
  77. package/dist/agent/engine/builder-engine.js +4 -0
  78. package/dist/agent/engine/builder-engine.js.map +1 -1
  79. package/dist/client/AgentPanel.d.ts.map +1 -1
  80. package/dist/client/AgentPanel.js +10 -3
  81. package/dist/client/AgentPanel.js.map +1 -1
  82. package/dist/client/ConnectBuilderCard.d.ts.map +1 -1
  83. package/dist/client/ConnectBuilderCard.js +5 -1
  84. package/dist/client/ConnectBuilderCard.js.map +1 -1
  85. package/dist/client/chat/markdown-renderer.d.ts.map +1 -1
  86. package/dist/client/chat/markdown-renderer.js +3 -2
  87. package/dist/client/chat/markdown-renderer.js.map +1 -1
  88. package/dist/client/error-format.d.ts +1 -1
  89. package/dist/client/error-format.d.ts.map +1 -1
  90. package/dist/client/error-format.js +1 -1
  91. package/dist/client/error-format.js.map +1 -1
  92. package/dist/client/index.d.ts +1 -0
  93. package/dist/client/index.d.ts.map +1 -1
  94. package/dist/client/index.js +1 -0
  95. package/dist/client/index.js.map +1 -1
  96. package/dist/client/integrations/IntegrationsPanel.d.ts.map +1 -1
  97. package/dist/client/integrations/IntegrationsPanel.js +3 -4
  98. package/dist/client/integrations/IntegrationsPanel.js.map +1 -1
  99. package/dist/client/resources/McpIntegrationDialog.d.ts.map +1 -1
  100. package/dist/client/resources/McpIntegrationDialog.js +21 -7
  101. package/dist/client/resources/McpIntegrationDialog.js.map +1 -1
  102. package/dist/client/resources/mcp-integration-catalog.d.ts.map +1 -1
  103. package/dist/client/resources/mcp-integration-catalog.js +2 -2
  104. package/dist/client/resources/mcp-integration-catalog.js.map +1 -1
  105. package/dist/client/settings/BackgroundAgentSection.d.ts.map +1 -1
  106. package/dist/client/settings/BackgroundAgentSection.js +5 -1
  107. package/dist/client/settings/BackgroundAgentSection.js.map +1 -1
  108. package/dist/client/settings/SettingsPanel.js +1 -1
  109. package/dist/client/settings/SettingsPanel.js.map +1 -1
  110. package/dist/client/settings/useBuilderStatus.d.ts.map +1 -1
  111. package/dist/client/settings/useBuilderStatus.js +2 -0
  112. package/dist/client/settings/useBuilderStatus.js.map +1 -1
  113. package/dist/collab/struct-routes.d.ts +1 -1
  114. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  115. package/dist/localization/default-messages.d.ts +0 -6
  116. package/dist/localization/default-messages.d.ts.map +1 -1
  117. package/dist/localization/default-messages.js +1 -7
  118. package/dist/localization/default-messages.js.map +1 -1
  119. package/dist/observability/routes.d.ts +5 -5
  120. package/dist/resources/handlers.d.ts +1 -1
  121. package/dist/secrets/routes.d.ts +9 -9
  122. package/dist/server/builder-browser.d.ts.map +1 -1
  123. package/dist/server/builder-browser.js +4 -0
  124. package/dist/server/builder-browser.js.map +1 -1
  125. package/dist/server/builder-design-systems.d.ts.map +1 -1
  126. package/dist/server/builder-design-systems.js +6 -1
  127. package/dist/server/builder-design-systems.js.map +1 -1
  128. package/dist/server/fusion-app.d.ts.map +1 -1
  129. package/dist/server/fusion-app.js +2 -1
  130. package/dist/server/fusion-app.js.map +1 -1
  131. package/dist/server/transcribe-voice.d.ts +1 -1
  132. package/dist/shared/builder-link-tracking.d.ts +14 -0
  133. package/dist/shared/builder-link-tracking.d.ts.map +1 -0
  134. package/dist/shared/builder-link-tracking.js +26 -0
  135. package/dist/shared/builder-link-tracking.js.map +1 -0
  136. package/dist/shared/index.d.ts +1 -0
  137. package/dist/shared/index.d.ts.map +1 -1
  138. package/dist/shared/index.js +1 -0
  139. package/dist/shared/index.js.map +1 -1
  140. package/docs/content/deployment.mdx +1 -1
  141. package/docs/content/frames.mdx +1 -1
  142. package/docs/content/integrations.mdx +27 -27
  143. package/docs/content/pr-visual-recap.mdx +17 -13
  144. package/docs/content/workspace-management.mdx +1 -1
  145. package/package.json +3 -2
  146. package/src/agent/engine/builder-engine.ts +4 -0
  147. package/src/client/AgentPanel.tsx +10 -4
  148. package/src/client/ConnectBuilderCard.tsx +5 -1
  149. package/src/client/chat/markdown-renderer.tsx +3 -5
  150. package/src/client/error-format.ts +2 -1
  151. package/src/client/index.ts +1 -0
  152. package/src/client/integrations/IntegrationsPanel.tsx +2 -18
  153. package/src/client/resources/McpIntegrationDialog.tsx +43 -32
  154. package/src/client/resources/mcp-integration-catalog.ts +4 -2
  155. package/src/client/settings/BackgroundAgentSection.tsx +5 -1
  156. package/src/client/settings/SettingsPanel.tsx +4 -4
  157. package/src/client/settings/useBuilderStatus.ts +3 -0
  158. package/src/localization/default-messages.ts +1 -7
  159. package/src/server/builder-browser.ts +4 -0
  160. package/src/server/builder-design-systems.ts +6 -1
  161. package/src/server/fusion-app.ts +5 -1
  162. package/src/shared/builder-link-tracking.ts +41 -0
  163. package/src/shared/index.ts +1 -0
@@ -0,0 +1,114 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { recordChange } from "@agent-native/core/server";
4
+ import { and, desc, eq } from "drizzle-orm";
5
+
6
+ import { getDb, schema } from "../db/index.js";
7
+ import {
8
+ FIRST_PARTY_DASHBOARD_ID,
9
+ repairFirstPartyRecurringUserPanels,
10
+ } from "./first-party-metric-catalog";
11
+
12
+ export async function repairPersistedFirstPartyDashboardQueries(): Promise<boolean> {
13
+ // guard:allow-unscoped — startup repair targets one fixed canonical dashboard
14
+ // and only replaces the exact shipped legacy SQL under an optimistic fence.
15
+ const db = getDb() as any;
16
+ const [row] = await db
17
+ .select({
18
+ id: schema.dashboards.id,
19
+ config: schema.dashboards.config,
20
+ kind: schema.dashboards.kind,
21
+ title: schema.dashboards.title,
22
+ updatedAt: schema.dashboards.updatedAt,
23
+ ownerEmail: schema.dashboards.ownerEmail,
24
+ orgId: schema.dashboards.orgId,
25
+ visibility: schema.dashboards.visibility,
26
+ })
27
+ .from(schema.dashboards)
28
+ .where(eq(schema.dashboards.id, FIRST_PARTY_DASHBOARD_ID));
29
+ if (!row || row.kind !== "sql" || typeof row.config !== "string") {
30
+ return false;
31
+ }
32
+
33
+ let config: Record<string, unknown>;
34
+ try {
35
+ config = JSON.parse(row.config) as Record<string, unknown>;
36
+ } catch {
37
+ return false;
38
+ }
39
+ const repaired = repairFirstPartyRecurringUserPanels(config);
40
+ if (!repaired.changed) return false;
41
+
42
+ const repairedAt = new Date().toISOString();
43
+ const revisionId = `dashrev-${Date.now()}-${randomUUID()}`;
44
+ const changed = await db.transaction(async (tx: any) => {
45
+ const updated = await tx
46
+ .update(schema.dashboards)
47
+ .set({
48
+ config: JSON.stringify(repaired.config),
49
+ updatedAt: repairedAt,
50
+ updatedBy: null,
51
+ })
52
+ .where(
53
+ and(
54
+ eq(schema.dashboards.id, FIRST_PARTY_DASHBOARD_ID),
55
+ eq(schema.dashboards.config, row.config),
56
+ eq(schema.dashboards.updatedAt, row.updatedAt),
57
+ ),
58
+ )
59
+ .returning({ id: schema.dashboards.id });
60
+ if (updated.length !== 1) return false;
61
+
62
+ await tx.insert(schema.dashboardRevisions).values({
63
+ id: revisionId,
64
+ dashboardId: row.id,
65
+ kind: row.kind,
66
+ title: row.title,
67
+ config: row.config,
68
+ createdAt: repairedAt,
69
+ createdBy: null,
70
+ ownerEmail: row.ownerEmail,
71
+ orgId: row.orgId,
72
+ });
73
+ const revisions = await tx
74
+ .select({ id: schema.dashboardRevisions.id })
75
+ .from(schema.dashboardRevisions)
76
+ .where(eq(schema.dashboardRevisions.dashboardId, row.id))
77
+ .orderBy(
78
+ desc(schema.dashboardRevisions.createdAt),
79
+ desc(schema.dashboardRevisions.id),
80
+ );
81
+ const retainedRevisionIds = new Set([revisionId]);
82
+ for (const revision of revisions) {
83
+ if (retainedRevisionIds.size >= 50) break;
84
+ retainedRevisionIds.add(revision.id);
85
+ }
86
+ for (const revision of revisions) {
87
+ if (retainedRevisionIds.has(revision.id)) continue;
88
+ await tx
89
+ .delete(schema.dashboardRevisions)
90
+ .where(eq(schema.dashboardRevisions.id, revision.id));
91
+ }
92
+ return true;
93
+ });
94
+ if (!changed) return false;
95
+
96
+ try {
97
+ recordChange({
98
+ source: "dashboards",
99
+ type: "change",
100
+ key: row.id,
101
+ ...(row.visibility === "public"
102
+ ? {}
103
+ : row.visibility === "org" && row.orgId
104
+ ? { orgId: row.orgId }
105
+ : { owner: row.ownerEmail }),
106
+ });
107
+ } catch (err) {
108
+ console.warn(
109
+ "[db] Canonical dashboard repair committed without a live change event:",
110
+ err instanceof Error ? err.message : err,
111
+ );
112
+ }
113
+ return true;
114
+ }
@@ -26,6 +26,8 @@
26
26
 
27
27
  export type MetricWindow = "30d" | "90d" | "all";
28
28
 
29
+ export const FIRST_PARTY_DASHBOARD_ID = "agent-native-templates-first-party";
30
+
29
31
  export interface FirstPartyMetric {
30
32
  /** Stable catalog key, also used as the default panel id. */
31
33
  key: string;
@@ -122,6 +124,7 @@ const USER_KEY_SQL = "NULLIF(user_key, '')";
122
124
  const RETENTION_ROLLING_DAYS = 7;
123
125
  const RETENTION_MIN_COHORT_SIZE = 5;
124
126
  const PER_TEMPLATE_RETENTION_MIN_COHORT_SIZE = 20;
127
+ const RECURRING_ACTIVITY_LOOKBACK_DAYS = 365;
125
128
 
126
129
  function daysAgoSql(days: number): string {
127
130
  const unit = days === 1 ? "day" : "days";
@@ -223,8 +226,85 @@ const RETENTION_OVER_TIME_SQL = `WITH base AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_S
223
226
  const ONE_DAY_RETENTION_BY_TEMPLATE_SQL = `WITH base AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${TEMPLATE_EXPR} AS template, ${EVENT_DATE_SQL} AS event_date, user_id FROM analytics_events WHERE ${SIGNED_IN_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER}), ranked_first_seen AS (SELECT user_key, template, event_date AS cohort_date, ROW_NUMBER() OVER (PARTITION BY user_key ORDER BY event_date, template) AS rn FROM base), first_seen AS (SELECT user_key, template, cohort_date FROM ranked_first_seen WHERE rn = 1), cohorts AS (SELECT user_key, template, cohort_date FROM first_seen WHERE cohort_date <= ${daysAgoSql(7)} AND ${dashboardTimeRangeFilter("cohort_date")}), cohort_sizes AS (SELECT template, COUNT(DISTINCT user_key) AS users FROM cohorts GROUP BY template), retained AS (SELECT c.template, COUNT(DISTINCT c.user_key) AS retained FROM cohorts c JOIN base b ON b.user_key = c.user_key AND b.event_date > c.cohort_date AND b.event_date <= to_char(c.cohort_date::date + INTERVAL '7 days', 'YYYY-MM-DD') GROUP BY c.template) SELECT cs.template, COALESCE(r.retained, 0) AS retained_users, cs.users AS cohort_users, COALESCE(r.retained::float / NULLIF(cs.users, 0), 0) AS rate FROM cohort_sizes cs LEFT JOIN retained r ON r.template = cs.template WHERE cs.users >= ${PER_TEMPLATE_RETENTION_MIN_COHORT_SIZE} ORDER BY rate DESC, cs.users DESC, cs.template`;
224
227
  const SEVEN_DAY_RETENTION_BY_TEMPLATE_SQL = `WITH base AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${TEMPLATE_EXPR} AS template, ${EVENT_DATE_SQL} AS event_date, user_id FROM analytics_events WHERE ${SIGNED_IN_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER}), ranked_first_seen AS (SELECT user_key, template, event_date AS cohort_date, ROW_NUMBER() OVER (PARTITION BY user_key ORDER BY event_date, template) AS rn FROM base), first_seen AS (SELECT user_key, template, cohort_date FROM ranked_first_seen WHERE rn = 1), cohorts AS (SELECT user_key, template, cohort_date FROM first_seen WHERE cohort_date <= ${daysAgoSql(14)} AND ${dashboardTimeRangeFilter("cohort_date")}), cohort_sizes AS (SELECT template, COUNT(DISTINCT user_key) AS users FROM cohorts GROUP BY template), retained AS (SELECT c.template, COUNT(DISTINCT c.user_key) AS retained FROM cohorts c JOIN base b ON b.user_key = c.user_key AND b.event_date >= to_char(c.cohort_date::date + INTERVAL '7 days', 'YYYY-MM-DD') AND b.event_date <= to_char(c.cohort_date::date + INTERVAL '14 days', 'YYYY-MM-DD') GROUP BY c.template) SELECT cs.template, COALESCE(r.retained, 0) AS retained_users, cs.users AS cohort_users, COALESCE(r.retained::float / NULLIF(cs.users, 0), 0) AS rate FROM cohort_sizes cs LEFT JOIN retained r ON r.template = cs.template WHERE cs.users >= ${PER_TEMPLATE_RETENTION_MIN_COHORT_SIZE} ORDER BY rate DESC, cs.users DESC, cs.template`;
225
228
  const SIGNUPS_BY_TEMPLATE_SQL = `SELECT ${TEMPLATE_EXPR} AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} GROUP BY ${TEMPLATE_EXPR} ORDER BY count DESC`;
226
- const RECURRING_USERS_BY_TEMPLATE_SQL = `WITH all_users AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${EVENT_DATE_SQL}, user_id, ${TEMPLATE_EXPR} AS template FROM analytics_events WHERE ${SIGNED_IN_PRODUCT_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER}), first_seen AS (SELECT user_key, MIN(event_date) AS first_date FROM all_users GROUP BY user_key) SELECT a.event_date AS date, a.template AS template, COUNT(DISTINCT a.user_key) AS users FROM all_users a JOIN first_seen f ON f.user_key = a.user_key WHERE a.event_date <> f.first_date AND a.template <> 'unknown' AND ${dashboardTimeRangeFilter("a.event_date")} GROUP BY 1, 2 ORDER BY date, template`;
227
- const RECURRING_USERS_BY_TEMPLATE_WEEKLY_SQL = `WITH all_users AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${EVENT_DATE_SQL}, user_id, ${TEMPLATE_EXPR} AS template FROM analytics_events WHERE ${SIGNED_IN_PRODUCT_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER}), first_seen AS (SELECT user_key, MIN(event_date) AS first_date FROM all_users GROUP BY user_key) SELECT to_char(date_trunc('week', a.event_date::date), 'YYYY-MM-DD') AS date, a.template AS template, COUNT(DISTINCT a.user_key) AS users FROM all_users a JOIN first_seen f ON f.user_key = a.user_key WHERE a.event_date <> f.first_date AND a.template <> 'unknown' AND ${dashboardTimeRangeFilter("a.event_date")} GROUP BY 1, 2 ORDER BY date, template`;
229
+ export const LEGACY_RECURRING_USERS_BY_TEMPLATE_SQL = `WITH all_users AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${EVENT_DATE_SQL}, user_id, ${TEMPLATE_EXPR} AS template FROM analytics_events WHERE ${SIGNED_IN_PRODUCT_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER}), first_seen AS (SELECT user_key, MIN(event_date) AS first_date FROM all_users GROUP BY user_key) SELECT a.event_date AS date, a.template AS template, COUNT(DISTINCT a.user_key) AS users FROM all_users a JOIN first_seen f ON f.user_key = a.user_key WHERE a.event_date <> f.first_date AND a.template <> 'unknown' AND ${dashboardTimeRangeFilter("a.event_date")} GROUP BY 1, 2 ORDER BY date, template`;
230
+ export const LEGACY_RECURRING_USERS_BY_TEMPLATE_WEEKLY_SQL = `WITH all_users AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${EVENT_DATE_SQL}, user_id, ${TEMPLATE_EXPR} AS template FROM analytics_events WHERE ${SIGNED_IN_PRODUCT_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER}), first_seen AS (SELECT user_key, MIN(event_date) AS first_date FROM all_users GROUP BY user_key) SELECT to_char(date_trunc('week', a.event_date::date), 'YYYY-MM-DD') AS date, a.template AS template, COUNT(DISTINCT a.user_key) AS users FROM all_users a JOIN first_seen f ON f.user_key = a.user_key WHERE a.event_date <> f.first_date AND a.template <> 'unknown' AND ${dashboardTimeRangeFilter("a.event_date")} GROUP BY 1, 2 ORDER BY date, template`;
231
+ const RECURRING_ACTIVITY_LOOKBACK_FILTER = `${EVENT_DATE_SQL} >= ${daysAgoSql(RECURRING_ACTIVITY_LOOKBACK_DAYS)}`;
232
+ const RECURRING_USERS_BY_TEMPLATE_SQL =
233
+ LEGACY_RECURRING_USERS_BY_TEMPLATE_SQL.replace(
234
+ `${DASHBOARD_EMAIL_FILTER}), first_seen`,
235
+ `${DASHBOARD_EMAIL_FILTER} AND ${RECURRING_ACTIVITY_LOOKBACK_FILTER}), first_seen`,
236
+ );
237
+ const RECURRING_USERS_BY_TEMPLATE_WEEKLY_SQL =
238
+ LEGACY_RECURRING_USERS_BY_TEMPLATE_WEEKLY_SQL.replace(
239
+ `${DASHBOARD_EMAIL_FILTER}), first_seen`,
240
+ `${DASHBOARD_EMAIL_FILTER} AND ${RECURRING_ACTIVITY_LOOKBACK_FILTER}), first_seen`,
241
+ );
242
+ const LEGACY_RECURRING_USERS_DESCRIPTION =
243
+ "Daily signed-in visitors who are NOT on their all-time first active day (Recurring only), stacked by inferred template/app used that day. Docs traffic and unknown template are excluded.";
244
+ const LEGACY_RECURRING_USERS_WEEKLY_DESCRIPTION =
245
+ "Weekly distinct signed-in visitors who are NOT on their all-time first active day (Recurring only), stacked by inferred template/app used that week. Weeks start Monday; docs traffic and unknown template are excluded.";
246
+ const RECURRING_USERS_DESCRIPTION =
247
+ "Daily signed-in visitors who are not on their first active day observed in the previous 365 days, stacked by inferred template/app used that day. Docs traffic and unknown template are excluded.";
248
+ const RECURRING_USERS_WEEKLY_DESCRIPTION =
249
+ "Weekly distinct signed-in visitors who are not on their first active day observed in the previous 365 days, stacked by inferred template/app used that week. Weeks start Monday; docs traffic and unknown template are excluded.";
250
+
251
+ export function repairFirstPartyRecurringUserPanels(
252
+ config: Record<string, unknown>,
253
+ ): { config: Record<string, unknown>; changed: boolean } {
254
+ if (!Array.isArray(config.panels)) return { config, changed: false };
255
+
256
+ const replacements = new Map([
257
+ [
258
+ "recurring-users-by-template",
259
+ {
260
+ legacySql: LEGACY_RECURRING_USERS_BY_TEMPLATE_SQL,
261
+ sql: RECURRING_USERS_BY_TEMPLATE_SQL,
262
+ legacyDescription: LEGACY_RECURRING_USERS_DESCRIPTION,
263
+ description: RECURRING_USERS_DESCRIPTION,
264
+ },
265
+ ],
266
+ [
267
+ "recurring-users-by-template-bar",
268
+ {
269
+ legacySql: LEGACY_RECURRING_USERS_BY_TEMPLATE_WEEKLY_SQL,
270
+ sql: RECURRING_USERS_BY_TEMPLATE_WEEKLY_SQL,
271
+ legacyDescription: LEGACY_RECURRING_USERS_WEEKLY_DESCRIPTION,
272
+ description: RECURRING_USERS_WEEKLY_DESCRIPTION,
273
+ },
274
+ ],
275
+ ]);
276
+ let changed = false;
277
+ const panels = config.panels.map((rawPanel) => {
278
+ if (!rawPanel || typeof rawPanel !== "object") return rawPanel;
279
+ const panel = rawPanel as Record<string, unknown>;
280
+ const replacement =
281
+ typeof panel.id === "string" ? replacements.get(panel.id) : undefined;
282
+ if (!replacement || panel.sql !== replacement.legacySql) return rawPanel;
283
+
284
+ changed = true;
285
+ const panelConfig =
286
+ panel.config && typeof panel.config === "object"
287
+ ? (panel.config as Record<string, unknown>)
288
+ : null;
289
+ return {
290
+ ...panel,
291
+ sql: replacement.sql,
292
+ ...(panelConfig
293
+ ? {
294
+ config: {
295
+ ...panelConfig,
296
+ ...(panelConfig.description === replacement.legacyDescription
297
+ ? { description: replacement.description }
298
+ : {}),
299
+ },
300
+ }
301
+ : {}),
302
+ };
303
+ });
304
+ return changed
305
+ ? { config: { ...config, panels }, changed }
306
+ : { config, changed };
307
+ }
228
308
  const DAU_BY_TEMPLATE_SQL = `SELECT ${EVENT_DATE_SQL} AS date, ${TEMPLATE_EXPR} AS template, COUNT(DISTINCT ${SIGNED_IN_ACTIVITY_KEY_SQL}) AS visitors FROM analytics_events WHERE ${SIGNED_IN_ACTIVITY_FILTER} AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} GROUP BY ${EVENT_DATE_SQL}, ${TEMPLATE_EXPR} ORDER BY date, template`;
229
309
  const WAU_BY_TEMPLATE_SQL = `WITH base AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS visitor_key, ${TEMPLATE_EXPR} AS template, ${EVENT_DATE_SQL} AS event_date, user_id FROM analytics_events WHERE ${SIGNED_IN_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${DASHBOARD_WAU_BASE_RANGE_FILTER}), days AS (SELECT DISTINCT event_date AS date FROM base WHERE ${DASHBOARD_EVENT_DATE_RANGE_FILTER}) SELECT d.date, b.template, COUNT(DISTINCT b.visitor_key) AS visitors FROM days d JOIN base b ON b.event_date >= to_char(d.date::date - INTERVAL '6 days', 'YYYY-MM-DD') AND b.event_date <= d.date GROUP BY d.date, b.template ORDER BY d.date, b.template`;
230
310
 
@@ -684,8 +764,7 @@ const ENTRIES: FirstPartyMetric[] = [
684
764
  valueKey: "users",
685
765
  },
686
766
  stacked: true,
687
- description:
688
- "Daily signed-in visitors who are NOT on their all-time first active day (Recurring only), stacked by inferred template/app used that day. Docs traffic and unknown template are excluded.",
767
+ description: RECURRING_USERS_DESCRIPTION,
689
768
  },
690
769
  },
691
770
  {
@@ -706,8 +785,7 @@ const ENTRIES: FirstPartyMetric[] = [
706
785
  valueKey: "users",
707
786
  },
708
787
  stacked: true,
709
- description:
710
- "Weekly distinct signed-in visitors who are NOT on their all-time first active day (Recurring only), stacked by inferred template/app used that week. Weeks start Monday; docs traffic and unknown template are excluded.",
788
+ description: RECURRING_USERS_WEEKLY_DESCRIPTION,
711
789
  },
712
790
  },
713
791
  {
@@ -8,6 +8,7 @@ import {
8
8
  // startup so the dashboard / analysis share actions know where to dispatch.
9
9
  import "../db/index.js";
10
10
  import * as schema from "../db/schema.js";
11
+ import { repairPersistedFirstPartyDashboardQueries } from "../lib/first-party-dashboard-repair.js";
11
12
 
12
13
  /**
13
14
  * Every Drizzle table exported from schema.ts. Filters out type-only and
@@ -1280,6 +1281,18 @@ const runAnalyticsMigrations = runMigrations(
1280
1281
  */
1281
1282
  export default async (nitroApp: any): Promise<void> => {
1282
1283
  await runAnalyticsMigrations(nitroApp);
1284
+ try {
1285
+ if (await repairPersistedFirstPartyDashboardQueries()) {
1286
+ console.info(
1287
+ "[db] Repaired bounded recurring-user queries on the canonical first-party dashboard.",
1288
+ );
1289
+ }
1290
+ } catch (err) {
1291
+ console.warn(
1292
+ "[db] Failed to repair canonical first-party dashboard queries (non-fatal):",
1293
+ err instanceof Error ? err.message : err,
1294
+ );
1295
+ }
1283
1296
  try {
1284
1297
  const summary = await ensureAdditiveColumns({
1285
1298
  db: getDbExec(),
@@ -27,6 +27,8 @@ export interface BulkMoveTarget {
27
27
 
28
28
  interface BulkActionToolbarProps {
29
29
  count: number;
30
+ allSelected?: boolean;
31
+ onSelectAll?: () => void;
30
32
  onArchive?: () => void;
31
33
  onMove?: (folderId: string | null) => void;
32
34
  onTrash?: () => void;
@@ -38,6 +40,8 @@ interface BulkActionToolbarProps {
38
40
 
39
41
  export function BulkActionToolbar({
40
42
  count,
43
+ allSelected = false,
44
+ onSelectAll,
41
45
  onArchive,
42
46
  onMove,
43
47
  onTrash,
@@ -53,17 +57,30 @@ export function BulkActionToolbar({
53
57
  return (
54
58
  <div
55
59
  className={cn(
56
- "flex w-fit max-w-full items-center gap-1 rounded-xl border border-border bg-popover px-3 py-2 shadow-lg",
60
+ "flex w-fit max-w-full items-center gap-1 rounded-xl bg-foreground px-3 py-2 text-background shadow-2xl ring-1 ring-black/10 dark:ring-white/10",
57
61
  )}
58
62
  >
59
- <span className="pe-2 text-xs font-medium text-foreground">
63
+ <span className="pe-2 text-xs font-medium">
60
64
  {t("clipsFinalRaw.selectedCount", { count })}
61
65
  </span>
62
- <div className="h-4 w-px bg-border" />
66
+ {onSelectAll && (
67
+ <Button
68
+ variant="ghost"
69
+ size="sm"
70
+ className="h-8 gap-1.5 text-background hover:bg-background/15 hover:text-background"
71
+ onClick={onSelectAll}
72
+ disabled={isPending}
73
+ >
74
+ {allSelected
75
+ ? t("clipsFinalRaw.deselectAll")
76
+ : t("clipsFinalRaw.selectAll")}
77
+ </Button>
78
+ )}
79
+ <div className="h-4 w-px bg-background/20" />
63
80
  <Button
64
81
  variant="ghost"
65
82
  size="sm"
66
- className="h-8 gap-1.5"
83
+ className="h-8 gap-1.5 text-background hover:bg-background/15 hover:text-background"
67
84
  onClick={onArchive}
68
85
  disabled={isPending}
69
86
  >
@@ -75,7 +92,7 @@ export function BulkActionToolbar({
75
92
  <Button
76
93
  variant="ghost"
77
94
  size="sm"
78
- className="h-8 gap-1.5"
95
+ className="h-8 gap-1.5 text-background hover:bg-background/15 hover:text-background"
79
96
  disabled={isPending}
80
97
  >
81
98
  <IconFolder className="h-3.5 w-3.5" /> {t("clipsFinalRaw.move")}
@@ -121,17 +138,17 @@ export function BulkActionToolbar({
121
138
  <Button
122
139
  variant="ghost"
123
140
  size="sm"
124
- className="h-8 gap-1.5 text-destructive hover:text-destructive"
141
+ className="h-8 gap-1.5 text-red-400 hover:bg-background/15 hover:text-red-400"
125
142
  onClick={onTrash}
126
143
  disabled={isPending}
127
144
  >
128
145
  <IconTrash className="h-3.5 w-3.5" /> {t("navigation.trash")}
129
146
  </Button>
130
- <div className="h-4 w-px bg-border mx-1" />
147
+ <div className="mx-1 h-4 w-px bg-background/20" />
131
148
  <button
132
149
  type="button"
133
150
  onClick={onClear}
134
- className="rounded p-1 text-muted-foreground hover:bg-accent"
151
+ className="rounded p-1 text-background/70 hover:bg-background/15 hover:text-background"
135
152
  aria-label={t("clipsFinalRaw.clearSelection")}
136
153
  >
137
154
  <IconX className="h-3.5 w-3.5" />
@@ -3,8 +3,12 @@ import {
3
3
  setClientAppState,
4
4
  } from "@agent-native/core/client/hooks";
5
5
  import { useT } from "@agent-native/core/client/i18n";
6
- import { IconAlertTriangle } from "@tabler/icons-react";
7
- import { useEffect, useMemo, useState } from "react";
6
+ import {
7
+ IconAlertTriangle,
8
+ IconChevronLeft,
9
+ IconChevronRight,
10
+ } from "@tabler/icons-react";
11
+ import { useCallback, useEffect, useMemo, useState } from "react";
8
12
  import { toast } from "sonner";
9
13
 
10
14
  import { CreateFolderDialog } from "@/components/library/create-folder-dialog";
@@ -13,6 +17,7 @@ import { Button } from "@/components/ui/button";
13
17
  import {
14
18
  useFolders,
15
19
  useRecordings,
20
+ useRecordingsCount,
16
21
  useTrashRecording,
17
22
  useArchiveRecording,
18
23
  useRestoreRecording,
@@ -54,6 +59,8 @@ function Skeleton() {
54
59
  );
55
60
  }
56
61
 
62
+ const PAGE_SIZE = 100;
63
+
57
64
  interface FolderTargetRow {
58
65
  id: string;
59
66
  parentId: string | null;
@@ -105,13 +112,38 @@ export function LibraryGrid({
105
112
  const t = useT();
106
113
  const [sort, setSort] = useState<SortKey>("recent");
107
114
  const [selected, setSelected] = useState<Set<string>>(new Set());
115
+ const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
108
116
  const selectionMode = selected.size > 0;
109
117
  const [sharingRec, setSharingRec] = useState<RecordingSummary | null>(null);
110
118
  const [createFolderTarget, setCreateFolderTarget] =
111
119
  useState<CreateFolderTarget | null>(null);
112
120
  const [isBulkPending, setIsBulkPending] = useState(false);
121
+ const [page, setPage] = useState(1);
113
122
  const selectionStateKey = useMemo(() => `selection:${getBrowserTabId()}`, []);
114
123
 
124
+ useEffect(() => {
125
+ setPage(1);
126
+ setSelected(new Set());
127
+ setLastSelectedId(null);
128
+ }, [view, folderId, spaceId, tagFilter, sort]);
129
+
130
+ const countArgs = useMemo(
131
+ () => ({
132
+ view,
133
+ folderId: folderId ?? null,
134
+ spaceId: spaceId ?? null,
135
+ tag: tagFilter ?? null,
136
+ }),
137
+ [view, folderId, spaceId, tagFilter],
138
+ );
139
+ const { data: totalCount } = useRecordingsCount(countArgs);
140
+ const total = totalCount ?? 0;
141
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
142
+
143
+ useEffect(() => {
144
+ if (page > totalPages) setPage(totalPages);
145
+ }, [page, totalPages]);
146
+
115
147
  const args: ListRecordingsArgs = useMemo(
116
148
  () => ({
117
149
  view,
@@ -119,8 +151,10 @@ export function LibraryGrid({
119
151
  spaceId: spaceId ?? null,
120
152
  tag: tagFilter ?? null,
121
153
  sort,
154
+ limit: PAGE_SIZE,
155
+ offset: (page - 1) * PAGE_SIZE,
122
156
  }),
123
- [view, folderId, spaceId, tagFilter, sort],
157
+ [view, folderId, spaceId, tagFilter, sort, page],
124
158
  );
125
159
 
126
160
  const { data, isLoading, isError, refetch, isRefetching } =
@@ -189,17 +223,53 @@ export function LibraryGrid({
189
223
  };
190
224
  }, [selectionStateKey]);
191
225
 
192
- const toggleSelect = (id: string) => {
226
+ const handleToggleSelect = useCallback(
227
+ (id: string, shiftKey = false) => {
228
+ setSelected((prev) => {
229
+ if (shiftKey && lastSelectedId && lastSelectedId !== id) {
230
+ const ids = recordings.map((r) => r.id);
231
+ const fromIndex = ids.indexOf(lastSelectedId);
232
+ const toIndex = ids.indexOf(id);
233
+ if (fromIndex !== -1 && toIndex !== -1) {
234
+ const [start, end] =
235
+ fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex];
236
+ const next = new Set(prev);
237
+ for (let i = start; i <= end; i++) next.add(ids[i]);
238
+ return next;
239
+ }
240
+ }
241
+ const next = new Set(prev);
242
+ if (next.has(id)) next.delete(id);
243
+ else next.add(id);
244
+ return next;
245
+ });
246
+ setLastSelectedId(id);
247
+ },
248
+ [lastSelectedId, recordings],
249
+ );
250
+
251
+ const allSelected =
252
+ recordings.length > 0 && recordings.every(({ id }) => selected.has(id));
253
+
254
+ const toggleSelectAll = useCallback(() => {
193
255
  setSelected((prev) => {
194
256
  const next = new Set(prev);
195
- if (next.has(id)) next.delete(id);
196
- else next.add(id);
257
+ const visibleIds = recordings.map(({ id }) => id);
258
+ const allVisibleSelected = visibleIds.every((id) => prev.has(id));
259
+
260
+ for (const id of visibleIds) {
261
+ if (allVisibleSelected) next.delete(id);
262
+ else next.add(id);
263
+ }
264
+
197
265
  return next;
198
266
  });
199
- };
267
+ setLastSelectedId(null);
268
+ }, [recordings]);
200
269
 
201
270
  const clearSelection = () => {
202
271
  setSelected(new Set());
272
+ setLastSelectedId(null);
203
273
  };
204
274
 
205
275
  const moveRecordings = async (
@@ -321,7 +391,7 @@ export function LibraryGrid({
321
391
  )}
322
392
 
323
393
  {/* Grid body */}
324
- <div className="relative flex min-h-0 flex-1 overflow-hidden">
394
+ <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
325
395
  <div
326
396
  className={cn(
327
397
  "min-h-0 flex-1 overflow-y-auto",
@@ -371,7 +441,7 @@ export function LibraryGrid({
371
441
  }
372
442
  selectionMode={canManageRecordings && selectionMode}
373
443
  onToggleSelect={
374
- canManageRecordings ? toggleSelect : undefined
444
+ canManageRecordings ? handleToggleSelect : undefined
375
445
  }
376
446
  onShare={(rec) => setSharingRec(rec)}
377
447
  moveTargets={moveTargets}
@@ -426,12 +496,51 @@ export function LibraryGrid({
426
496
  </div>
427
497
  </div>
428
498
 
499
+ {!isLoading && recordings.length > 0 && totalPages > 1 && (
500
+ <div className="flex shrink-0 items-center justify-between gap-3 border-t border-border px-5 py-2.5">
501
+ <span className="text-xs text-muted-foreground">
502
+ {t("libraryGrid.paginationRange", {
503
+ start: (page - 1) * PAGE_SIZE + 1,
504
+ end: (page - 1) * PAGE_SIZE + recordings.length,
505
+ total,
506
+ })}
507
+ </span>
508
+ <div className="flex items-center gap-2">
509
+ <Button
510
+ variant="outline"
511
+ size="sm"
512
+ className="gap-1"
513
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
514
+ disabled={page <= 1}
515
+ >
516
+ <IconChevronLeft className="h-3.5 w-3.5" />
517
+ {t("libraryGrid.paginationPrevious")}
518
+ </Button>
519
+ <span className="text-xs text-muted-foreground">
520
+ {t("libraryGrid.paginationPage", { page, totalPages })}
521
+ </span>
522
+ <Button
523
+ variant="outline"
524
+ size="sm"
525
+ className="gap-1"
526
+ onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
527
+ disabled={page >= totalPages}
528
+ >
529
+ {t("libraryGrid.paginationNext")}
530
+ <IconChevronRight className="h-3.5 w-3.5" />
531
+ </Button>
532
+ </div>
533
+ </div>
534
+ )}
535
+
429
536
  {/* Keep selected-library actions visible while the recording list scrolls. */}
430
537
  {canManageRecordings && selected.size > 0 && (
431
538
  <div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 flex justify-center px-4 pb-4">
432
539
  <div className="pointer-events-auto max-w-full overflow-x-auto">
433
540
  <BulkActionToolbar
434
541
  count={selected.size}
542
+ allSelected={allSelected}
543
+ onSelectAll={toggleSelectAll}
435
544
  moveTargets={moveTargets}
436
545
  onArchive={async () => {
437
546
  setIsBulkPending(true);
@@ -566,7 +566,7 @@ export function LibraryLayout({ children }: LibraryLayoutProps) {
566
566
  browserTabId={getBrowserTabId()}
567
567
  >
568
568
  {/* Main content area */}
569
- <div className="flex min-w-0 flex-1 flex-col">
569
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col">
570
570
  {!pageOwnsToolbar && (
571
571
  <header className="flex shrink-0 items-center gap-3 border-b border-border px-5 py-3">
572
572
  <button
@@ -65,7 +65,7 @@ interface RecordingCardProps {
65
65
  recording: RecordingSummary;
66
66
  selected?: boolean;
67
67
  selectionMode?: boolean;
68
- onToggleSelect?: (id: string) => void;
68
+ onToggleSelect?: (id: string, shiftKey: boolean) => void;
69
69
  onShare?: (rec: RecordingSummary) => void;
70
70
  onMove?: (rec: RecordingSummary, folderId: string | null) => void;
71
71
  moveTargets?: BulkMoveTarget[];
@@ -147,18 +147,18 @@ export function RecordingCard({
147
147
  const handleLinkClick = useCallback(
148
148
  (event: React.MouseEvent<HTMLAnchorElement>) => {
149
149
  if (
150
- !selectionMode ||
150
+ !onToggleSelect ||
151
151
  event.button !== 0 ||
152
152
  event.metaKey ||
153
153
  event.ctrlKey ||
154
- event.shiftKey ||
155
- event.altKey
154
+ event.altKey ||
155
+ (!selectionMode && !event.shiftKey)
156
156
  ) {
157
157
  return;
158
158
  }
159
159
 
160
160
  event.preventDefault();
161
- onToggleSelect?.(recording.id);
161
+ onToggleSelect(recording.id, event.shiftKey);
162
162
  },
163
163
  [onToggleSelect, recording.id, selectionMode],
164
164
  );
@@ -166,7 +166,7 @@ export function RecordingCard({
166
166
  const handleCheckbox = useCallback(
167
167
  (e: React.MouseEvent) => {
168
168
  e.stopPropagation();
169
- onToggleSelect?.(recording.id);
169
+ onToggleSelect?.(recording.id, e.shiftKey);
170
170
  },
171
171
  [onToggleSelect, recording.id],
172
172
  );
@@ -243,8 +243,10 @@ export function RecordingCard({
243
243
  >
244
244
  <Checkbox
245
245
  checked={selected}
246
- onCheckedChange={() => onToggleSelect?.(recording.id)}
247
- onClick={(e) => e.stopPropagation()}
246
+ onClick={(e) => {
247
+ e.stopPropagation();
248
+ onToggleSelect?.(recording.id, e.shiftKey);
249
+ }}
248
250
  className="h-3.5 w-3.5"
249
251
  />
250
252
  </div>
@@ -646,10 +646,10 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
646
646
  const togglePlayback = useCallback(() => {
647
647
  const v = videoRef.current;
648
648
  if (!v) return;
649
- if (!v.paused || isPlaying) {
650
- pauseVideo();
651
- return;
652
- }
649
+ // A finished clip must always replay from the start, even when the browser
650
+ // left `paused` false at end of stream (MSE end-of-stream / DB-duration
651
+ // mismatch) or `isPlaying` is stale — otherwise the toggle below would
652
+ // pause an already-ended element and the play button appears to do nothing.
653
653
  if (v.ended) {
654
654
  try {
655
655
  v.currentTime = 0;
@@ -657,6 +657,12 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
657
657
  } catch {
658
658
  // Let the normal play attempt report a media error if the seek fails.
659
659
  }
660
+ requestPlay();
661
+ return;
662
+ }
663
+ if (!v.paused || isPlaying) {
664
+ pauseVideo();
665
+ return;
660
666
  }
661
667
  requestPlay();
662
668
  }, [isPlaying, pauseVideo, requestPlay]);
@@ -833,6 +833,10 @@ All notable user-facing changes to Clips are documented here. Open it any time f
833
833
  loadFailedBody:
834
834
  "Something went wrong while loading this list. Your recordings are safe — try again.",
835
835
  retry: "Retry",
836
+ paginationRange: "{{start}}–{{end}} of {{total}}",
837
+ paginationPrevious: "Previous",
838
+ paginationNext: "Next",
839
+ paginationPage: "Page {{page}} of {{totalPages}}",
836
840
  },
837
841
  notificationsRoute: {
838
842
  pageTitle: "Notifications · Clips",
@@ -1370,6 +1374,8 @@ All notable user-facing changes to Clips are documented here. Open it any time f
1370
1374
  clipsFinalRaw: {
1371
1375
  splitAtPlayhead: "Split at playhead (S)",
1372
1376
  selectedCount: "{{count}} selected",
1377
+ selectAll: "Select all",
1378
+ deselectAll: "Deselect all",
1373
1379
  move: "Move",
1374
1380
  moveSelected: "Move {{count}} selected",
1375
1381
  current: "Current",