@agent-native/core 0.102.1 → 0.102.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 (113) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +9 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/AgentPanel.tsx +7 -0
  5. package/corpus/core/src/client/AssistantChat.tsx +7 -0
  6. package/corpus/core/src/client/agent-chat-adapter.ts +7 -1
  7. package/corpus/core/src/client/chat/message-components.tsx +17 -3
  8. package/corpus/core/src/client/composer/ComposerPlusMenu.tsx +96 -6
  9. package/corpus/core/src/client/composer/asset-picker-url.ts +80 -0
  10. package/corpus/core/src/client/error-format.ts +24 -0
  11. package/corpus/core/src/client/resources/McpIntegrationDialog.tsx +8 -1
  12. package/corpus/templates/assets/app/routes/library.tsx +69 -0
  13. package/corpus/templates/assets/changelog/2026-07-15-fixed-top-level-image-picker-selections-returning-to-the-com.md +6 -0
  14. package/corpus/templates/clips/actions/disconnect-calendar.ts +57 -9
  15. package/corpus/templates/clips/actions/generate-workflow.ts +102 -59
  16. package/corpus/templates/clips/actions/get-recording-player-data.ts +64 -1
  17. package/corpus/templates/clips/actions/regenerate-summary.ts +7 -0
  18. package/corpus/templates/clips/actions/regenerate-title.ts +29 -11
  19. package/corpus/templates/clips/app/components/editor/editor-layout.tsx +22 -11
  20. package/corpus/templates/clips/app/components/editor/thumbnail-picker.tsx +87 -9
  21. package/corpus/templates/clips/app/components/editor/trim-handles.tsx +15 -0
  22. package/corpus/templates/clips/app/components/editor/waveform.tsx +21 -8
  23. package/corpus/templates/clips/app/components/library/folder-tree.tsx +52 -0
  24. package/corpus/templates/clips/app/components/player/settings-panel.tsx +86 -46
  25. package/corpus/templates/clips/app/components/player/share-dialog.tsx +43 -37
  26. package/corpus/templates/clips/app/components/player/video-player.tsx +45 -1
  27. package/corpus/templates/clips/app/i18n/ar-SA.ts +2 -0
  28. package/corpus/templates/clips/app/i18n/de-DE.ts +2 -0
  29. package/corpus/templates/clips/app/i18n/en-US.ts +2 -0
  30. package/corpus/templates/clips/app/i18n/es-ES.ts +2 -0
  31. package/corpus/templates/clips/app/i18n/fr-FR.ts +2 -0
  32. package/corpus/templates/clips/app/i18n/hi-IN.ts +2 -0
  33. package/corpus/templates/clips/app/i18n/ja-JP.ts +2 -0
  34. package/corpus/templates/clips/app/i18n/ko-KR.ts +2 -0
  35. package/corpus/templates/clips/app/i18n/pt-BR.ts +2 -0
  36. package/corpus/templates/clips/app/i18n/zh-CN.ts +2 -0
  37. package/corpus/templates/clips/app/i18n/zh-TW.ts +2 -0
  38. package/corpus/templates/clips/app/lib/timestamp-mapping.ts +14 -0
  39. package/corpus/templates/clips/app/routes/_app.library._index.tsx +1 -2
  40. package/corpus/templates/clips/app/routes/_index.tsx +1 -2
  41. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +71 -31
  42. package/corpus/templates/clips/app/routes/share.$shareId.tsx +4 -1
  43. package/corpus/templates/clips/changelog/2026-07-15-ai-workflow-generation-now-stays-visible-avoids-duplicate-re.md +6 -0
  44. package/corpus/templates/clips/changelog/2026-07-15-clip-playback-and-cta-controls-remain-interactive-through-ed.md +6 -0
  45. package/corpus/templates/clips/changelog/2026-07-15-clip-titles-no-longer-describe-the-product-as-a-loom-alterna.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-07-15-description-regeneration-now-opens-the-agent-chat-so-progres.md +6 -0
  47. package/corpus/templates/clips/changelog/2026-07-15-direct-recording-urls-now-respect-share-link-and-password-ac.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-15-disconnecting-a-calendar-now-clears-unrecorded-synced-meetin.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-15-folders-now-expose-rename-and-delete-actions-directly-in-the.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-07-15-recording-pages-now-explain-sign-in-access-and-avoid-showing.md +6 -0
  51. package/corpus/templates/clips/changelog/2026-07-15-the-clips-extension-quick-actions-toolbar-can-be-moved-out-o.md +6 -0
  52. package/corpus/templates/clips/changelog/2026-07-15-the-editor-now-skips-cut-sections-during-preview-and-keeps-t.md +6 -0
  53. package/corpus/templates/clips/changelog/2026-07-15-thumbnail-capture-now-updates-previews-while-scrubbing-and-l.md +6 -0
  54. package/corpus/templates/clips/changelog/2026-07-15-windows-permission-links-open-the-matching-settings-page.md +6 -0
  55. package/corpus/templates/clips/chrome-extension/src/content-script.ts +117 -2
  56. package/corpus/templates/clips/chrome-extension/src/overlay.ts +27 -0
  57. package/corpus/templates/clips/desktop/src-tauri/tauri.conf.json +1 -1
  58. package/corpus/templates/clips/server/lib/calendar-event-meetings.ts +139 -101
  59. package/corpus/templates/clips/server/lib/recording-page-access.ts +35 -0
  60. package/corpus/templates/design/app/components/design/design-canvas/external-preview.ts +7 -3
  61. package/corpus/templates/design/changelog/2026-07-15-prototype-print-and-download-buttons-now-work-in-preview.md +6 -0
  62. package/corpus/templates/slides/actions/extract-pdf.ts +1 -1
  63. package/corpus/templates/slides/actions/import-file.ts +4 -1
  64. package/corpus/templates/slides/app/components/editor/ImageGenPanel.tsx +1 -1
  65. package/corpus/templates/slides/app/components/editor/ImageSearchPanel.tsx +9 -12
  66. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +29 -21
  67. package/corpus/templates/slides/app/i18n/ar-SA.ts +1 -0
  68. package/corpus/templates/slides/app/i18n/de-DE.ts +1 -0
  69. package/corpus/templates/slides/app/i18n/en-US.ts +1 -0
  70. package/corpus/templates/slides/app/i18n/es-ES.ts +1 -0
  71. package/corpus/templates/slides/app/i18n/fr-FR.ts +1 -0
  72. package/corpus/templates/slides/app/i18n/hi-IN.ts +1 -0
  73. package/corpus/templates/slides/app/i18n/ja-JP.ts +1 -0
  74. package/corpus/templates/slides/app/i18n/ko-KR.ts +1 -0
  75. package/corpus/templates/slides/app/i18n/pt-BR.ts +1 -0
  76. package/corpus/templates/slides/app/i18n/zh-CN.ts +1 -0
  77. package/corpus/templates/slides/app/i18n/zh-TW.ts +1 -0
  78. package/corpus/templates/slides/changelog/2026-07-15-image-search-and-generation-controls-now-use-the-registered-.md +6 -0
  79. package/corpus/templates/slides/changelog/2026-07-15-pdf-deck-references-can-be-imported-for-slide-inspiration-ag.md +6 -0
  80. package/corpus/templates/slides/changelog/2026-07-15-stable-style-inspector-dock.md +6 -0
  81. package/dist/client/AgentPanel.d.ts.map +1 -1
  82. package/dist/client/AgentPanel.js +5 -0
  83. package/dist/client/AgentPanel.js.map +1 -1
  84. package/dist/client/AssistantChat.d.ts.map +1 -1
  85. package/dist/client/AssistantChat.js +7 -0
  86. package/dist/client/AssistantChat.js.map +1 -1
  87. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  88. package/dist/client/agent-chat-adapter.js +8 -1
  89. package/dist/client/agent-chat-adapter.js.map +1 -1
  90. package/dist/client/chat/message-components.d.ts +4 -0
  91. package/dist/client/chat/message-components.d.ts.map +1 -1
  92. package/dist/client/chat/message-components.js +10 -3
  93. package/dist/client/chat/message-components.js.map +1 -1
  94. package/dist/client/composer/ComposerPlusMenu.d.ts.map +1 -1
  95. package/dist/client/composer/ComposerPlusMenu.js +58 -6
  96. package/dist/client/composer/ComposerPlusMenu.js.map +1 -1
  97. package/dist/client/composer/asset-picker-url.d.ts +16 -0
  98. package/dist/client/composer/asset-picker-url.d.ts.map +1 -0
  99. package/dist/client/composer/asset-picker-url.js +64 -0
  100. package/dist/client/composer/asset-picker-url.js.map +1 -0
  101. package/dist/client/error-format.d.ts.map +1 -1
  102. package/dist/client/error-format.js +17 -0
  103. package/dist/client/error-format.js.map +1 -1
  104. package/dist/client/resources/McpIntegrationDialog.d.ts.map +1 -1
  105. package/dist/client/resources/McpIntegrationDialog.js +6 -1
  106. package/dist/client/resources/McpIntegrationDialog.js.map +1 -1
  107. package/dist/observability/routes.d.ts +5 -5
  108. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  109. package/dist/resources/handlers.d.ts +2 -2
  110. package/dist/secrets/routes.d.ts +9 -9
  111. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  112. package/dist/server/transcribe-voice.d.ts +1 -1
  113. package/package.json +1 -1
@@ -15,7 +15,10 @@
15
15
  */
16
16
 
17
17
  import { defineAction } from "@agent-native/core";
18
- import { writeAppState } from "@agent-native/core/application-state";
18
+ import {
19
+ readAppState,
20
+ writeAppState,
21
+ } from "@agent-native/core/application-state";
19
22
  import { assertAccess } from "@agent-native/core/sharing";
20
23
  import { eq } from "drizzle-orm";
21
24
  import { z } from "zod";
@@ -51,6 +54,11 @@ Output markdown.`,
51
54
  Keep it concise, warm, and professional.`,
52
55
  } as const;
53
56
 
57
+ // Actions for the same recording can arrive concurrently when the player and
58
+ // agent both retry a request. Keep the read-check-write/enqueue sequence
59
+ // single-flight within this runtime so only one request can claim generation.
60
+ const workflowGenerationLocks = new Set<string>();
61
+
54
62
  export default defineAction({
55
63
  description:
56
64
  "Ask the agent to generate a structured workflow doc (pr/sop/ticket/email) from this recording's transcript (and the full video when Include full video is enabled). The agent writes the result to clips-workflow-<recordingId> in application_state.",
@@ -61,71 +69,106 @@ export default defineAction({
61
69
  run: async (args) => {
62
70
  await assertAccess("recording", args.recordingId, "viewer");
63
71
 
64
- const db = getDb();
65
- const [rec] = await db
66
- .select()
67
- .from(schema.recordings)
68
- .where(eq(schema.recordings.id, args.recordingId))
69
- .limit(1);
70
- if (!rec) throw new Error(`Recording not found: ${args.recordingId}`);
72
+ const stateKey = `clips-workflow-${args.recordingId}`;
73
+ if (workflowGenerationLocks.has(stateKey)) {
74
+ return {
75
+ queued: false,
76
+ duplicate: true,
77
+ recordingId: args.recordingId,
78
+ kind: args.kind,
79
+ stateKey,
80
+ };
81
+ }
82
+ workflowGenerationLocks.add(stateKey);
71
83
 
72
- const [transcript] = await db
73
- .select()
74
- .from(schema.recordingTranscripts)
75
- .where(eq(schema.recordingTranscripts.recordingId, args.recordingId))
76
- .limit(1);
84
+ try {
85
+ const db = getDb();
86
+ const [rec] = await db
87
+ .select()
88
+ .from(schema.recordings)
89
+ .where(eq(schema.recordings.id, args.recordingId))
90
+ .limit(1);
91
+ if (!rec) throw new Error(`Recording not found: ${args.recordingId}`);
77
92
 
78
- const stateKey = `clips-workflow-${args.recordingId}`;
79
- const includeFullVideoInAi = await readIncludeFullVideoInAi();
93
+ const [transcript] = await db
94
+ .select()
95
+ .from(schema.recordingTranscripts)
96
+ .where(eq(schema.recordingTranscripts.recordingId, args.recordingId))
97
+ .limit(1);
80
98
 
81
- // Seed the output state with a "generating" placeholder so the UI can show
82
- // a loading state immediately.
83
- await writeAppState(stateKey, {
84
- kind: args.kind,
85
- status: "generating",
86
- recordingId: args.recordingId,
87
- requestedAt: new Date().toISOString(),
88
- } as any);
99
+ const includeFullVideoInAi = await readIncludeFullVideoInAi();
89
100
 
90
- const baseMessage =
91
- `Generate a ${args.kind.toUpperCase()} workflow document from recording ${args.recordingId} ` +
92
- `(title: "${rec.title}"). Read the transcript from this request's context. ` +
93
- `${KIND_PROMPTS[args.kind]} ` +
94
- `Then write the final markdown to application_state key "${stateKey}" as ` +
95
- `\`{ kind: "${args.kind}", status: "ready", content: "...", recordingId: "${args.recordingId}" }\`. ` +
96
- `Finish by replying in chat with the same generated markdown so the user can read it immediately.`;
101
+ const existing = await readAppState(stateKey).catch(() => null);
102
+ if (existing?.status === "generating") {
103
+ const requestedAt = Date.parse(String(existing.requestedAt ?? ""));
104
+ const isRecent =
105
+ !Number.isFinite(requestedAt) ||
106
+ Date.now() - requestedAt < 10 * 60_000;
107
+ if (isRecent) {
108
+ return {
109
+ queued: false,
110
+ duplicate: true,
111
+ recordingId: args.recordingId,
112
+ kind: existing.kind ?? args.kind,
113
+ stateKey,
114
+ };
115
+ }
116
+ }
97
117
 
98
- const request = {
99
- kind: "generate-workflow" as const,
100
- workflowKind: args.kind,
101
- recordingId: args.recordingId,
102
- requestedAt: new Date().toISOString(),
103
- recordingTitle: rec.title,
104
- recordingDescription: rec.description,
105
- transcriptStatus: transcript?.status ?? "pending",
106
- transcriptText: transcript?.fullText ?? "",
107
- stateKey,
108
- instructions: KIND_PROMPTS[args.kind],
109
- includeFullVideoInAi,
110
- message: withFullVideoAiInstructions(
111
- baseMessage,
112
- args.recordingId,
118
+ // Seed the output state with a "generating" placeholder so the UI can show
119
+ // a loading state immediately.
120
+ await writeAppState(stateKey, {
121
+ kind: args.kind,
122
+ status: "generating",
123
+ recordingId: args.recordingId,
124
+ requestedAt: new Date().toISOString(),
125
+ } as any);
126
+
127
+ const baseMessage =
128
+ `Generate a ${args.kind.toUpperCase()} workflow document from recording ${args.recordingId} ` +
129
+ `(title: "${rec.title}"). Read the transcript from this request's context. ` +
130
+ `${KIND_PROMPTS[args.kind]} ` +
131
+ `Then write the final markdown to application_state key "${stateKey}" as ` +
132
+ `\`{ kind: "${args.kind}", status: "ready", content: "...", recordingId: "${args.recordingId}" }\`. ` +
133
+ `Finish by replying in chat with the same generated markdown so the user can read it immediately.`;
134
+
135
+ const request = {
136
+ kind: "generate-workflow" as const,
137
+ workflowKind: args.kind,
138
+ recordingId: args.recordingId,
139
+ requestedAt: new Date().toISOString(),
140
+ recordingTitle: rec.title,
141
+ recordingDescription: rec.description,
142
+ transcriptStatus: transcript?.status ?? "pending",
143
+ transcriptText: transcript?.fullText ?? "",
144
+ stateKey,
145
+ instructions: KIND_PROMPTS[args.kind],
113
146
  includeFullVideoInAi,
114
- ),
115
- };
147
+ message: withFullVideoAiInstructions(
148
+ baseMessage,
149
+ args.recordingId,
150
+ includeFullVideoInAi,
151
+ ),
152
+ };
116
153
 
117
- await writeAppState(`clips-ai-request-${args.recordingId}`, request as any);
118
- await writeAppState("refresh-signal", { ts: Date.now() });
154
+ await writeAppState(
155
+ `clips-ai-request-${args.recordingId}`,
156
+ request as any,
157
+ );
158
+ await writeAppState("refresh-signal", { ts: Date.now() });
119
159
 
120
- console.log(
121
- `Delegation queued: generate-workflow (${args.kind}) for ${args.recordingId}`,
122
- );
123
- return {
124
- queued: true,
125
- recordingId: args.recordingId,
126
- kind: args.kind,
127
- stateKey,
128
- includeFullVideoInAi,
129
- };
160
+ console.log(
161
+ `Delegation queued: generate-workflow (${args.kind}) for ${args.recordingId}`,
162
+ );
163
+ return {
164
+ queued: true,
165
+ recordingId: args.recordingId,
166
+ kind: args.kind,
167
+ stateKey,
168
+ includeFullVideoInAi,
169
+ };
170
+ } finally {
171
+ workflowGenerationLocks.delete(stateKey);
172
+ }
130
173
  },
131
174
  });
@@ -20,12 +20,20 @@
20
20
  import { defineAction, embedApp } from "@agent-native/core";
21
21
  import { readAppState } from "@agent-native/core/application-state";
22
22
  import { buildDeepLink } from "@agent-native/core/server";
23
+ import {
24
+ getRequestOrgId,
25
+ getRequestUserEmail,
26
+ } from "@agent-native/core/server/request-context";
23
27
  import { resolveAccess, ForbiddenError } from "@agent-native/core/sharing";
24
- import { asc, eq } from "drizzle-orm";
28
+ import { and, asc, eq, or, sql } from "drizzle-orm";
25
29
  import { z } from "zod";
26
30
 
27
31
  import { getDb, schema } from "../server/db/index.js";
28
32
  import { resolvePlayerVideoUrl } from "../server/lib/player-video-url.js";
33
+ import {
34
+ canOpenDirectRecordingPage,
35
+ isRecordingExpired,
36
+ } from "../server/lib/recording-page-access.js";
29
37
  import { parseSpaceIds } from "../server/lib/recordings.js";
30
38
  import { parseBrowserDiagnosticsRow } from "../shared/browser-diagnostics.js";
31
39
  import {
@@ -106,6 +114,61 @@ export default defineAction({
106
114
  const db = getDb();
107
115
  const rec: any = access.resource;
108
116
 
117
+ if (isRecordingExpired(rec.expiresAt)) {
118
+ throw new ForbiddenError("Recording has expired");
119
+ }
120
+
121
+ let hasExplicitShare = access.role === "owner";
122
+ if (rec.visibility === "public" && access.role !== "owner") {
123
+ const userEmail = getRequestUserEmail()?.trim().toLowerCase();
124
+ const orgId = getRequestOrgId();
125
+ const principals = [];
126
+ if (userEmail) {
127
+ principals.push(
128
+ and(
129
+ eq(schema.recordingShares.principalType, "user"),
130
+ sql`lower(${schema.recordingShares.principalId}) = ${userEmail}`,
131
+ ),
132
+ );
133
+ }
134
+ if (orgId) {
135
+ principals.push(
136
+ and(
137
+ eq(schema.recordingShares.principalType, "org"),
138
+ eq(schema.recordingShares.principalId, orgId),
139
+ ),
140
+ );
141
+ }
142
+ if (principals.length > 0) {
143
+ const [share] = await db
144
+ .select({ id: schema.recordingShares.id })
145
+ .from(schema.recordingShares)
146
+ .where(
147
+ and(
148
+ eq(schema.recordingShares.resourceId, args.recordingId),
149
+ or(...principals),
150
+ ),
151
+ )
152
+ .limit(1);
153
+ hasExplicitShare = Boolean(share);
154
+ } else {
155
+ hasExplicitShare = false;
156
+ }
157
+ }
158
+
159
+ if (
160
+ !canOpenDirectRecordingPage({
161
+ role: access.role,
162
+ visibility: rec.visibility,
163
+ hasPassword: Boolean(rec.password),
164
+ hasExplicitShare,
165
+ })
166
+ ) {
167
+ throw new ForbiddenError(
168
+ "Open this recording from its share link instead of the direct recording URL",
169
+ );
170
+ }
171
+
109
172
  const [transcript] = await db
110
173
  .select()
111
174
  .from(schema.recordingTranscripts)
@@ -22,6 +22,12 @@ export default defineAction({
22
22
  "Ask the agent to regenerate this recording's description/summary based on its transcript (and the full video when Include full video is enabled).",
23
23
  schema: z.object({
24
24
  recordingId: z.string().describe("Recording ID"),
25
+ openInChat: z
26
+ .boolean()
27
+ .optional()
28
+ .describe(
29
+ "When true, focus the queued generation request in the agent chat instead of keeping it hidden.",
30
+ ),
25
31
  }),
26
32
  run: async (args) => {
27
33
  await assertAccess("recording", args.recordingId, "editor");
@@ -74,6 +80,7 @@ export default defineAction({
74
80
  transcriptStatus: transcript?.status ?? "pending",
75
81
  transcriptText: transcript?.fullText ?? "",
76
82
  includeFullVideoInAi,
83
+ openInChat: args.openInChat === true,
77
84
  message: withFullVideoAiInstructions(
78
85
  baseMessage,
79
86
  args.recordingId,
@@ -59,7 +59,9 @@ function buildTitleContext({
59
59
  }): string | undefined {
60
60
  const parts: string[] = [];
61
61
  if (currentTitle && !isDefaultTitle(currentTitle)) {
62
- parts.push(`Current title: ${currentTitle}`);
62
+ parts.push(
63
+ `Current title: ${currentTitle}\nWhen regenerating, do not return this title verbatim; choose a more specific alternative when the transcript supports one.`,
64
+ );
63
65
  }
64
66
  if (agentsContext) parts.push(agentsContext);
65
67
  return parts.length > 0 ? parts.join("\n\n") : undefined;
@@ -237,9 +239,14 @@ export default defineAction({
237
239
 
238
240
  if (!fresh) throw new Error(`Recording not found: ${args.recordingId}`);
239
241
 
242
+ const titleMatchesCurrent =
243
+ fresh.title?.trim().toLocaleLowerCase() ===
244
+ generatedTitle.trim().toLocaleLowerCase();
245
+
240
246
  if (
241
- isAutoTitleReplaceable(fresh.title, fresh.titleSource) ||
242
- fresh.title === rec.title
247
+ !titleMatchesCurrent &&
248
+ (isAutoTitleReplaceable(fresh.title, fresh.titleSource) ||
249
+ fresh.title === rec.title)
243
250
  ) {
244
251
  await db
245
252
  .update(schema.recordings)
@@ -272,12 +279,18 @@ export default defineAction({
272
279
  };
273
280
  }
274
281
 
275
- return {
276
- updated: false,
277
- skipped: true,
278
- reason: "Recording title changed before generation completed",
279
- recordingId: args.recordingId,
280
- };
282
+ if (!titleMatchesCurrent) {
283
+ return {
284
+ updated: false,
285
+ skipped: true,
286
+ reason: "Recording title changed before generation completed",
287
+ recordingId: args.recordingId,
288
+ };
289
+ }
290
+
291
+ console.warn(
292
+ `[clips] AI title regeneration returned the existing title for ${args.recordingId}; queueing a refinement instead`,
293
+ );
281
294
  }
282
295
  } catch (err) {
283
296
  builderCreditsPaused = isBuilderCreditsExhaustedMessage(
@@ -302,9 +315,14 @@ export default defineAction({
302
315
 
303
316
  if (!fresh) throw new Error(`Recording not found: ${args.recordingId}`);
304
317
 
318
+ const titleMatchesCurrent =
319
+ fresh.title?.trim().toLocaleLowerCase() ===
320
+ fallbackTitle.trim().toLocaleLowerCase();
321
+
305
322
  if (
306
- isAutoTitleReplaceable(fresh.title, fresh.titleSource) ||
307
- fresh.title === rec.title
323
+ !titleMatchesCurrent &&
324
+ (isAutoTitleReplaceable(fresh.title, fresh.titleSource) ||
325
+ fresh.title === rec.title)
308
326
  ) {
309
327
  await db
310
328
  .update(schema.recordings)
@@ -63,6 +63,7 @@ import {
63
63
  parseEdits,
64
64
  getExcludedRanges,
65
65
  formatMs,
66
+ skipExcludedRange,
66
67
  type EditsJson,
67
68
  } from "@/lib/timestamp-mapping";
68
69
  import { cn } from "@/lib/utils";
@@ -435,10 +436,15 @@ export function EditorLayout({ recordingId, className }: EditorLayoutProps) {
435
436
  useEffect(() => {
436
437
  const v = videoRef.current;
437
438
  if (!v) return;
438
- const onTime = () => setPlayheadMs(v.currentTime * 1000);
439
+ const onTime = () => {
440
+ const rawMs = v.currentTime * 1000;
441
+ const visibleMs = skipExcludedRange(rawMs, excludedRanges, durationMs);
442
+ if (visibleMs !== rawMs) v.currentTime = visibleMs / 1000;
443
+ setPlayheadMs(visibleMs);
444
+ };
439
445
  v.addEventListener("timeupdate", onTime);
440
446
  return () => v.removeEventListener("timeupdate", onTime);
441
- }, [videoUrl]);
447
+ }, [durationMs, excludedRanges, videoUrl]);
442
448
 
443
449
  // Expose the in-editor state so the agent can read "the user is editing and scrubbed to X".
444
450
  useEffect(() => {
@@ -510,11 +516,15 @@ export function EditorLayout({ recordingId, className }: EditorLayoutProps) {
510
516
  [recordingId, trim],
511
517
  );
512
518
 
513
- const seek = useCallback((ms: number) => {
514
- const v = videoRef.current;
515
- if (v) v.currentTime = ms / 1000;
516
- setPlayheadMs(ms);
517
- }, []);
519
+ const seek = useCallback(
520
+ (ms: number) => {
521
+ const visibleMs = skipExcludedRange(ms, excludedRanges, durationMs);
522
+ const v = videoRef.current;
523
+ if (v) v.currentTime = visibleMs / 1000;
524
+ setPlayheadMs(visibleMs);
525
+ },
526
+ [durationMs, excludedRanges],
527
+ );
518
528
 
519
529
  // --- keyboard shortcuts -------------------------------------------------
520
530
  useEffect(() => {
@@ -585,8 +595,8 @@ export function EditorLayout({ recordingId, className }: EditorLayoutProps) {
585
595
 
586
596
  // Default selection window so the TrimHandles have something to render.
587
597
  const effectiveSelection = selectionRange ?? {
588
- startMs: Math.max(0, playheadMs - 1000),
589
- endMs: Math.min(durationMs || 1_000, playheadMs + 1000),
598
+ startMs: 0,
599
+ endMs: durationMs || 1_000,
590
600
  };
591
601
 
592
602
  if (playerDataQuery.isLoading) {
@@ -684,7 +694,8 @@ export function EditorLayout({ recordingId, className }: EditorLayoutProps) {
684
694
  playheadMs={playheadMs}
685
695
  durationMs={durationMs}
686
696
  excludedRanges={excludedRanges}
687
- selectionRange={selectionRange}
697
+ selectionRange={effectiveSelection}
698
+ splitPoints={splitPoints}
688
699
  activityRanges={transcriptSegments}
689
700
  onSeek={seek}
690
701
  scrollLeft={scrollLeft}
@@ -707,6 +718,7 @@ export function EditorLayout({ recordingId, className }: EditorLayoutProps) {
707
718
  value={effectiveSelection}
708
719
  onChange={setSelectionRange}
709
720
  durationMs={durationMs}
721
+ splitPoints={splitPoints}
710
722
  />
711
723
  </div>
712
724
  </div>
@@ -727,7 +739,6 @@ export function EditorLayout({ recordingId, className }: EditorLayoutProps) {
727
739
  durationMs={durationMs}
728
740
  playheadMs={playheadMs}
729
741
  chapters={chapters}
730
- excludedRanges={excludedRanges}
731
742
  splitPoints={splitPoints}
732
743
  onSeek={seek}
733
744
  onClickChapter={(c) => seek(c.startMs)}
@@ -4,6 +4,7 @@ import {
4
4
  IconPhotoEdit,
5
5
  IconUpload,
6
6
  IconLoader2,
7
+ IconX,
7
8
  } from "@tabler/icons-react";
8
9
  import { useEffect, useRef, useState } from "react";
9
10
  import { toast } from "sonner";
@@ -55,6 +56,10 @@ export function ThumbnailPicker({
55
56
  const [gifDataUrl, setGifDataUrl] = useState<string | null>(null);
56
57
 
57
58
  const videoRef = useRef<HTMLVideoElement | null>(null);
59
+ const framePreviewRef = useRef<HTMLVideoElement | null>(null);
60
+ const gifVideoRef = useRef<HTMLVideoElement | null>(null);
61
+ const gifPreviewRef = useRef<HTMLVideoElement | null>(null);
62
+ const uploadInputRef = useRef<HTMLInputElement | null>(null);
58
63
 
59
64
  const mutation = useActionMutation("set-thumbnail");
60
65
 
@@ -65,6 +70,7 @@ export function ThumbnailPicker({
65
70
  setFrameDataUrl(null);
66
71
  setGifDataUrl(null);
67
72
  setGifProgress(null);
73
+ if (uploadInputRef.current) uploadInputRef.current.value = "";
68
74
  }
69
75
  }, [open]);
70
76
 
@@ -98,6 +104,18 @@ export function ThumbnailPicker({
98
104
  }
99
105
  };
100
106
 
107
+ const seekPreview = (video: HTMLVideoElement | null, timeMs: number) => {
108
+ if (!video || !Number.isFinite(timeMs)) return;
109
+ const apply = () => {
110
+ video.currentTime = Math.max(0, timeMs) / 1000;
111
+ };
112
+ if (video.readyState === HTMLMediaElement.HAVE_NOTHING) {
113
+ video.addEventListener("loadedmetadata", apply, { once: true });
114
+ } else {
115
+ apply();
116
+ }
117
+ };
118
+
101
119
  const handleGifGenerate = async () => {
102
120
  if (!videoUrl) return;
103
121
  try {
@@ -187,6 +205,7 @@ export function ThumbnailPicker({
187
205
  <TabsContent value="upload" className="py-4 space-y-3">
188
206
  <Label>{t("thumbnailPicker.uploadImage")}</Label>
189
207
  <input
208
+ ref={uploadInputRef}
190
209
  type="file"
191
210
  accept="image/*"
192
211
  onChange={(e) => {
@@ -196,14 +215,39 @@ export function ThumbnailPicker({
196
215
  reader.onload = () => setUploadDataUrl(reader.result as string);
197
216
  reader.readAsDataURL(file);
198
217
  }}
199
- className="text-sm"
218
+ className="sr-only"
200
219
  />
220
+ <Button
221
+ type="button"
222
+ variant="secondary"
223
+ size="sm"
224
+ onClick={() => uploadInputRef.current?.click()}
225
+ >
226
+ <IconUpload className="mr-1 h-4 w-4" />
227
+ {t("shareDialog.chooseFile")}
228
+ </Button>
201
229
  {uploadDataUrl ? (
202
- <img
203
- src={uploadDataUrl}
204
- alt={t("thumbnailPicker.uploadedPreview")}
205
- className="max-h-60 rounded border border-border"
206
- />
230
+ <div className="relative w-fit">
231
+ <img
232
+ src={uploadDataUrl}
233
+ alt={t("thumbnailPicker.uploadedPreview")}
234
+ className="max-h-60 rounded border border-border"
235
+ />
236
+ <Button
237
+ type="button"
238
+ variant="secondary"
239
+ size="icon"
240
+ className="absolute right-1 top-1 h-7 w-7"
241
+ aria-label={t("shareDialog.remove")}
242
+ onClick={() => {
243
+ setUploadDataUrl(null);
244
+ if (uploadInputRef.current)
245
+ uploadInputRef.current.value = "";
246
+ }}
247
+ >
248
+ <IconX className="h-4 w-4" />
249
+ </Button>
250
+ </div>
207
251
  ) : currentThumbnailUrl ? (
208
252
  <img
209
253
  src={currentThumbnailUrl}
@@ -235,7 +279,13 @@ export function ThumbnailPicker({
235
279
  max={Math.max(1000, durationMs)}
236
280
  step={100}
237
281
  value={[frameTime]}
238
- onValueChange={([v]) => setFrameTime(v)}
282
+ onValueChange={([v]) => {
283
+ const next = v ?? 0;
284
+ setFrameTime(next);
285
+ setFrameDataUrl(null);
286
+ seekPreview(videoRef.current, next);
287
+ seekPreview(framePreviewRef.current, next);
288
+ }}
239
289
  />
240
290
  <Button
241
291
  size="sm"
@@ -256,6 +306,15 @@ export function ThumbnailPicker({
256
306
  alt={t("thumbnailPicker.capturedFrame")}
257
307
  className="w-full rounded border border-border mt-1"
258
308
  />
309
+ ) : videoUrl ? (
310
+ <video
311
+ ref={framePreviewRef}
312
+ src={videoUrl}
313
+ className="w-full rounded border border-border bg-black mt-1"
314
+ crossOrigin="anonymous"
315
+ preload="auto"
316
+ muted
317
+ />
259
318
  ) : (
260
319
  <div className="w-full aspect-video rounded border border-dashed border-border flex items-center justify-center text-xs text-muted-foreground mt-1">
261
320
  {t("thumbnailPicker.capturePreview")}
@@ -269,6 +328,7 @@ export function ThumbnailPicker({
269
328
  <div className="grid grid-cols-2 gap-4">
270
329
  <div>
271
330
  <video
331
+ ref={gifVideoRef}
272
332
  src={videoUrl ?? undefined}
273
333
  className="w-full rounded border border-border bg-black"
274
334
  crossOrigin="anonymous"
@@ -287,7 +347,13 @@ export function ThumbnailPicker({
287
347
  max={Math.max(0, durationMs - gifDuration)}
288
348
  step={100}
289
349
  value={[gifStart]}
290
- onValueChange={([v]) => setGifStart(v)}
350
+ onValueChange={([v]) => {
351
+ const next = v ?? 0;
352
+ setGifStart(next);
353
+ setGifDataUrl(null);
354
+ seekPreview(gifVideoRef.current, next);
355
+ seekPreview(gifPreviewRef.current, next);
356
+ }}
291
357
  />
292
358
  </div>
293
359
  <div>
@@ -301,7 +367,10 @@ export function ThumbnailPicker({
301
367
  max={10000}
302
368
  step={100}
303
369
  value={[gifDuration]}
304
- onValueChange={([v]) => setGifDuration(v)}
370
+ onValueChange={([v]) => {
371
+ setGifDuration(v ?? 500);
372
+ setGifDataUrl(null);
373
+ }}
305
374
  />
306
375
  </div>
307
376
  <Button
@@ -331,6 +400,15 @@ export function ThumbnailPicker({
331
400
  alt={t("thumbnailPicker.animatedPreview")}
332
401
  className="w-full rounded border border-border mt-1"
333
402
  />
403
+ ) : videoUrl ? (
404
+ <video
405
+ ref={gifPreviewRef}
406
+ src={videoUrl}
407
+ className="w-full rounded border border-border bg-black mt-1"
408
+ crossOrigin="anonymous"
409
+ preload="auto"
410
+ muted
411
+ />
334
412
  ) : (
335
413
  <div className="w-full aspect-video rounded border border-dashed border-border flex items-center justify-center text-xs text-muted-foreground mt-1">
336
414
  {t("thumbnailPicker.generateGifPreview")}
@@ -13,6 +13,7 @@ export interface TrimHandlesProps {
13
13
  /** Fires when the user releases the mouse after drag. */
14
14
  onCommit?: (value: { startMs: number; endMs: number }) => void;
15
15
  durationMs: number;
16
+ splitPoints?: number[];
16
17
  className?: string;
17
18
  }
18
19
 
@@ -44,6 +45,7 @@ export function TrimHandles({
44
45
  onChange,
45
46
  onCommit,
46
47
  durationMs,
48
+ splitPoints = [],
47
49
  className,
48
50
  }: TrimHandlesProps) {
49
51
  const rootRef = useRef<HTMLDivElement | null>(null);
@@ -111,6 +113,9 @@ export function TrimHandles({
111
113
 
112
114
  const startX = (value.startMs / Math.max(durationMs, 1)) * width;
113
115
  const endX = (value.endMs / Math.max(durationMs, 1)) * width;
116
+ const visibleSplitPoints = splitPoints.filter(
117
+ (ms) => ms > value.startMs && ms < value.endMs, // i18n-ignore numeric range predicate, not visible UI copy
118
+ );
114
119
 
115
120
  return (
116
121
  <div
@@ -133,6 +138,16 @@ export function TrimHandles({
133
138
  <div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-full bg-foreground text-background text-[10px] px-1.5 py-0.5 rounded font-mono whitespace-nowrap">
134
139
  {formatMs(value.endMs - value.startMs)}
135
140
  </div>
141
+ {visibleSplitPoints.map((splitMs) => (
142
+ <div
143
+ key={splitMs}
144
+ className="absolute top-0 h-full w-0.5 -translate-x-1/2 bg-rose-500/95"
145
+ style={{
146
+ left: `${((splitMs - value.startMs) / Math.max(value.endMs - value.startMs, 1)) * 100}%`,
147
+ }}
148
+ aria-hidden="true"
149
+ />
150
+ ))}
136
151
  </div>
137
152
 
138
153
  {/* Left handle */}