@agent-native/core 0.161.2 → 0.161.6

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 (42) hide show
  1. package/corpus/templates/analytics/actions/update-dashboard.ts +8 -0
  2. package/corpus/templates/clips/actions/save-browser-transcript.ts +20 -4
  3. package/corpus/templates/clips/app/components/meetings/transcript-bubbles.tsx +167 -35
  4. package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +8 -1
  5. package/corpus/templates/clips/desktop/src/lib/transcription-engine.ts +39 -2
  6. package/corpus/templates/forms/server/lib/public-form-ssr.ts +3 -0
  7. package/corpus/templates/slides/actions/list-decks.ts +36 -1
  8. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +29 -17
  9. package/corpus/templates/slides/app/context/DeckContext.tsx +17 -6
  10. package/dist/agent/engine/builder-engine.js +30 -15
  11. package/dist/agent/engine/types.d.ts +19 -0
  12. package/dist/agent/engine/types.js +3 -0
  13. package/dist/agent/production-agent.d.ts +56 -1
  14. package/dist/agent/production-agent.js +130 -3
  15. package/dist/agent/run-manager.d.ts +15 -4
  16. package/dist/agent/run-manager.js +26 -0
  17. package/dist/agent/run-store.d.ts +5 -5
  18. package/dist/agent/run-store.js +52 -15
  19. package/dist/agent/thread-data-builder.js +7 -0
  20. package/dist/agent/types.d.ts +10 -0
  21. package/dist/cli/code-agent-connector.js +6 -1
  22. package/dist/client/AssistantChat.d.ts +1 -0
  23. package/dist/client/AssistantChat.js +10 -1
  24. package/dist/client/MultiTabAssistantChat.js +33 -1
  25. package/dist/client/sse-event-processor.js +11 -7
  26. package/dist/client/use-chat-threads.js +9 -9
  27. package/dist/db/client.js +10 -2
  28. package/dist/db/create-get-db.js +42 -0
  29. package/dist/observability/routes.d.ts +3 -3
  30. package/dist/progress/routes.d.ts +1 -1
  31. package/dist/resources/handlers.d.ts +1 -1
  32. package/dist/secrets/routes.d.ts +9 -9
  33. package/dist/server/onboarding-html.js +22 -77
  34. package/dist/server/poll.d.ts +5 -5
  35. package/dist/server/poll.js +19 -26
  36. package/dist/server/realtime-token.d.ts +1 -1
  37. package/dist/server/transcribe-voice.d.ts +1 -1
  38. package/dist/shared/auth-copy.d.ts +7 -0
  39. package/dist/shared/auth-copy.js +77 -0
  40. package/dist/shared/mcp-embed-headers.js +8 -4
  41. package/dist/vite/client.js +94 -3
  42. package/package.json +1 -1
@@ -9,6 +9,7 @@ import { z } from "zod";
9
9
  import { interpolate } from "../app/pages/adhoc/sql-dashboard/interpolate";
10
10
  import { dryRunQuery } from "../server/lib/bigquery";
11
11
  import { queueDashboardCollabSync } from "../server/lib/dashboard-collab-sync";
12
+ import { serializeProgramDescriptorInput } from "../server/lib/dashboard-panel-query";
12
13
  import { validateFirstPartyDashboardTimeScope } from "../server/lib/dashboard-time-scope";
13
14
  import {
14
15
  upsertDashboard,
@@ -362,6 +363,13 @@ export function validateDashboardConfig(
362
363
  if (!isSection && !isExtension && !validSources.has(p.source as string)) {
363
364
  return `panel[${i}].source must be 'bigquery', 'ga4', 'amplitude', 'first-party', 'demo', 'prometheus', or 'program' (got '${p.source}'). source selects the backend — put the PromQL/SQL/table name or program descriptor in sql, not here.`;
364
365
  }
366
+ if (p.source === "program") {
367
+ try {
368
+ serializeProgramDescriptorInput(p.sql);
369
+ } catch (e: any) {
370
+ return `panel[${i}] "${p.title || p.id}" program descriptor is invalid: ${e?.message ?? e}`;
371
+ }
372
+ }
365
373
  if (isExtension) {
366
374
  const cfg = p.config as Record<string, unknown> | undefined;
367
375
  const extensionId =
@@ -26,8 +26,19 @@ import { buildCaptionSegmentsFromText } from "../shared/transcript-segments.js";
26
26
  import { booleanParam } from "./lib/cli-params.js";
27
27
  import { isAutoTitleReplaceable } from "./lib/title-source.js";
28
28
 
29
- function nativeSegmentsJson(fullText: string): string {
30
- return JSON.stringify(buildCaptionSegmentsFromText(fullText));
29
+ // web-speech and macos-native are both mic-only engines — see
30
+ // transcription-engine.ts's file header. When a caller sends fullText with
31
+ // no segments (word-level timings were never captured), there's no
32
+ // per-line source to preserve, but for these two engines there's also no
33
+ // ambiguity: every word came from the mic. Leaving source undefined here
34
+ // falls through to resolveSpeaker's default and renders the whole thing as
35
+ // "Them". Whisper mixes mic + system, so it has no safe single-speaker guess.
36
+ function nativeSegmentsJson(
37
+ fullText: string,
38
+ engineSource?: "web-speech" | "macos-native" | "whisper",
39
+ ): string {
40
+ const source = engineSource && engineSource !== "whisper" ? "mic" : undefined;
41
+ return JSON.stringify(buildCaptionSegmentsFromText(fullText, null, source));
31
42
  }
32
43
 
33
44
  // Real transcript segments supplied by a caller that already has accurate
@@ -45,6 +56,11 @@ const segmentSchema = z
45
56
  text: z.string(),
46
57
  // Stream the segment came from; the transcript UI maps mic→"Me", system→"Them".
47
58
  source: z.enum(["mic", "system"]).optional(),
59
+ // Diarized speaker for this segment, when the provider identifies one.
60
+ // Declared so zod keeps it: an undeclared key is stripped before the array
61
+ // is serialized, which would drop a provider's speaker labels on save and
62
+ // leave the transcript unable to tell its speakers apart on reload.
63
+ speaker: z.string().nullable().optional(),
48
64
  })
49
65
  .transform((s) => {
50
66
  if (s.endMs < s.startMs) {
@@ -74,7 +90,7 @@ export default defineAction({
74
90
  .array(segmentSchema)
75
91
  .optional()
76
92
  .describe(
77
- "Real transcript segments with accurate timestamps (ms). When provided, stored verbatim instead of synthesizing timings from fullText.",
93
+ "Transcript segments with per-segment timings (ms) and the `mic`/`system` stream each came from. Stored verbatim when provided, instead of synthesizing timings from fullText. Timings are the engine's own where it reported them; the mic-only engines report none, so callers may send estimates to keep each segment's speaker.",
78
94
  ),
79
95
  overwriteReady: booleanParam
80
96
  .default(false)
@@ -97,7 +113,7 @@ export default defineAction({
97
113
  const segmentsJson =
98
114
  args.segments && args.segments.length > 0
99
115
  ? JSON.stringify(args.segments)
100
- : nativeSegmentsJson(fullText);
116
+ : nativeSegmentsJson(fullText, args.source);
101
117
 
102
118
  const [current] = await db
103
119
  .select({
@@ -61,8 +61,99 @@ export interface SpeakerIdentity {
61
61
  initialsSource: AttendeeStackParticipant | string;
62
62
  isOwner: boolean;
63
63
  accentClass: string;
64
+ /** The capture could not tell speakers apart, so this transcript names
65
+ * nobody — the row renders without an avatar or label rather than claiming
66
+ * a speaker we cannot know. */
67
+ unattributed?: boolean;
64
68
  }
65
69
 
70
+ /**
71
+ * Whether a transcript carries enough signal to name who said what.
72
+ *
73
+ * A capture that only ever produced one speaker signal cannot distinguish two
74
+ * people: the mic-only fallback engines tag every segment `mic` (the remote
75
+ * side reaches the transcript only as bleed into the same microphone), and
76
+ * cloud transcription of a single mixed track tags nothing at all. Attributing
77
+ * those to the recording owner reads as fact and is wrong for every line the
78
+ * other person spoke — including in the AI summary and action items derived
79
+ * from it. A per-segment `speaker` label from a diarizing provider counts as
80
+ * signal even when `source` is absent.
81
+ *
82
+ * Only meaningful when two people could have spoken; a solo recording that is
83
+ * all mic genuinely is all one person.
84
+ */
85
+ export function transcriptDistinguishesSpeakers(
86
+ segments: TranscriptSegment[],
87
+ participants: AttendeeStackParticipant[],
88
+ ownerEmail?: string | null,
89
+ ): boolean {
90
+ if (countPossibleSpeakers(participants, ownerEmail) < 2) return true;
91
+ const signals = new Set<string>();
92
+ for (const segment of segments) {
93
+ const signal = speakerSignal(segment);
94
+ if (signal) signals.add(signal);
95
+ if (signals.size > 1) return true;
96
+ }
97
+ return false;
98
+ }
99
+
100
+ /**
101
+ * What one segment claims about who was speaking, as a comparable key.
102
+ *
103
+ * A generic placeholder is not an identity — it names a side of the
104
+ * conversation, which is what `source` already says. Counting `speaker: "Me"`
105
+ * and a plain `source: "mic"` as two different speakers would mark a mic-only
106
+ * transcript distinguishable and hand the remote side's bleed back to the
107
+ * owner's name, so placeholders resolve to the side they mean instead. A real
108
+ * name wins over `source`, since a diarizing provider knows more than the
109
+ * stream split does; a placeholder yields to it.
110
+ */
111
+ function speakerSignal(segment: TranscriptSegment): string | null {
112
+ const speaker = segment.speaker?.trim();
113
+ const side = placeholderSide(speaker);
114
+ if (speaker && !side) return `speaker:${normalizeSpeaker(speaker)}`;
115
+ if (segment.source) return `source:${segment.source}`;
116
+ // No placeholder and no source is an absence of information, not a side.
117
+ // Defaulting it to "system" here would make a transcript that mixes tagged
118
+ // and untagged segments look like two speakers.
119
+ return side ? `source:${side}` : null;
120
+ }
121
+
122
+ /**
123
+ * How many people could have spoken in this meeting.
124
+ *
125
+ * The participant roster is the calendar attendee list, which routinely omits
126
+ * the recording owner — `create-meeting` deliberately does not synthesize a row
127
+ * for a non-attendee owner, because that table feeds the public share payload.
128
+ * So counting rows alone reads an owner-plus-one-attendee meeting as solo and
129
+ * hands a mic-only transcript back to attribution, which is what labels the
130
+ * remote side's bleed as the owner.
131
+ *
132
+ * A withheld owner (`null`, from the public share page) still counts: it means
133
+ * an owner exists and is not among the participants. An owner we were never
134
+ * told about (`undefined`) also counts, because "we cannot name them" is not
135
+ * the same as "they are not there" — the cost of over-counting is a lost label,
136
+ * and the cost of under-counting is a false one.
137
+ */
138
+ function countPossibleSpeakers(
139
+ participants: AttendeeStackParticipant[],
140
+ ownerEmail?: string | null,
141
+ ): number {
142
+ const ownerInRoster = ownerEmail
143
+ ? Boolean(findParticipant(ownerEmail, participants))
144
+ : false;
145
+ return participants.length + (ownerInRoster ? 0 : 1);
146
+ }
147
+
148
+ const UNATTRIBUTED_SPEAKER: SpeakerIdentity = {
149
+ key: "unattributed",
150
+ label: null,
151
+ initialsSource: "",
152
+ isOwner: false,
153
+ accentClass: "",
154
+ unattributed: true,
155
+ };
156
+
66
157
  const SPEAKER_ACCENTS = [
67
158
  "bg-accent text-accent-foreground",
68
159
  "bg-secondary text-secondary-foreground",
@@ -75,6 +166,37 @@ function normalizeSpeaker(value: string): string {
75
166
  return value.trim().toLowerCase().replace(/\s+/g, " ");
76
167
  }
77
168
 
169
+ // Some providers (and our own seed fixture) tag unresolved segments with a
170
+ // literal placeholder word instead of leaving `speaker` blank. These name a
171
+ // side of the conversation rather than a person, so neither the label nor the
172
+ // attribution check may treat one as an identity.
173
+ const GENERIC_MIC_SPEAKER = /^(me|self|you)$/i;
174
+ const GENERIC_SYSTEM_SPEAKER = /^them$/i;
175
+
176
+ /** The side a generic placeholder names, or null when it names a person. */
177
+ function placeholderSide(
178
+ label: string | null | undefined,
179
+ ): "mic" | "system" | null {
180
+ const speaker = label?.trim();
181
+ if (!speaker) return null;
182
+ if (GENERIC_MIC_SPEAKER.test(speaker)) return "mic";
183
+ if (GENERIC_SYSTEM_SPEAKER.test(speaker)) return "system";
184
+ return null;
185
+ }
186
+
187
+ /**
188
+ * Which side of the conversation a segment belongs to.
189
+ *
190
+ * `source` is the real signal, but a segment can arrive with only a
191
+ * placeholder speaker on it. Falling straight through to "system" then drops
192
+ * every "Me" into the remote group, which is how a transcript labelled purely
193
+ * with placeholders renders entirely as "Them" — the original bug. Read the
194
+ * side the placeholder names before defaulting.
195
+ */
196
+ function segmentSide(segment: TranscriptSegment): "mic" | "system" {
197
+ return segment.source ?? placeholderSide(segment.speaker) ?? "system";
198
+ }
199
+
78
200
  // Exported for regression testing — see transcript-bubbles.test.ts. These
79
201
  // are pure functions with no dependency on the component itself.
80
202
  export function findParticipant(
@@ -139,22 +261,23 @@ export function resolveSpeaker(
139
261
  participants: AttendeeStackParticipant[],
140
262
  ownerEmail?: string | null,
141
263
  ): SpeakerIdentity {
142
- const source = segment.source === "mic" ? "mic" : "system";
264
+ const source = segmentSide(segment);
143
265
  const rawSpeaker = segment.speaker?.trim();
144
266
  const participant =
145
267
  findParticipant(segment.speaker, participants) ||
146
268
  resolveParticipantForSpeaker(source, participants, ownerEmail);
147
269
  const participantName = participant?.name?.trim();
148
270
  const resolvedLabel = participantName || rawSpeaker;
149
- // Some providers (and our own seed fixture) tag unresolved segments with a
150
- // literal placeholder word instead of leaving speaker blank. Treat those the
151
- // same as "no label" on both sides so the UI falls back to the translated
152
- // Me/Them string instead of rendering the raw English placeholder verbatim.
271
+ // Treat a placeholder as "no label" so the UI falls back to the translated
272
+ // Me/Them string instead of rendering the raw English word verbatim. Only
273
+ // the placeholder matching this segment's own side counts: "Them" on a mic
274
+ // segment is a contradiction, not a placeholder, and keeping it visible is
275
+ // better than silently dropping it.
153
276
  const isGenericPlaceholderLabel =
154
277
  !!resolvedLabel &&
155
278
  (source === "mic"
156
- ? /^(me|self|you)$/i.test(resolvedLabel)
157
- : /^them$/i.test(resolvedLabel));
279
+ ? GENERIC_MIC_SPEAKER.test(resolvedLabel)
280
+ : GENERIC_SYSTEM_SPEAKER.test(resolvedLabel));
158
281
  const label = isGenericPlaceholderLabel
159
282
  ? null
160
283
  : participantName || rawSpeaker || null;
@@ -203,9 +326,16 @@ function groupConsecutive(
203
326
  participants: AttendeeStackParticipant[],
204
327
  ownerEmail?: string | null,
205
328
  ): BubbleGroup[] {
329
+ const attributable = transcriptDistinguishesSpeakers(
330
+ segments,
331
+ participants,
332
+ ownerEmail,
333
+ );
206
334
  const groups: BubbleGroup[] = [];
207
335
  segments.forEach((seg, index) => {
208
- const speaker = resolveSpeaker(seg, participants, ownerEmail);
336
+ const speaker = attributable
337
+ ? resolveSpeaker(seg, participants, ownerEmail)
338
+ : UNATTRIBUTED_SPEAKER;
209
339
  const last = groups[groups.length - 1];
210
340
  if (last && last.speaker.key === speaker.key) {
211
341
  last.segments.push({ seg, index });
@@ -509,38 +639,40 @@ export function TranscriptBubbles({
509
639
  key={`${group.speaker.key}:${gi}`}
510
640
  className="space-y-0.5"
511
641
  >
512
- <div className="flex h-6 items-center gap-2">
513
- <Avatar
514
- className={cn(
515
- "size-6 shrink-0",
516
- group.speaker.accentClass,
517
- )}
518
- >
519
- <AvatarFallback
642
+ {!group.speaker.unattributed && (
643
+ <div className="flex h-6 items-center gap-2">
644
+ <Avatar
520
645
  className={cn(
521
- "text-[9px] font-semibold",
646
+ "size-6 shrink-0",
522
647
  group.speaker.accentClass,
523
648
  )}
524
649
  >
525
- {attendeeInitials(group.speaker.initialsSource)}
526
- </AvatarFallback>
527
- </Avatar>
528
- <div className="flex min-h-6 items-center">
529
- <span
530
- className={cn(
531
- "text-xs font-semibold leading-6",
532
- group.speaker.isOwner
533
- ? "text-primary"
534
- : "text-foreground",
535
- )}
536
- >
537
- {group.speaker.label ||
538
- (group.speaker.isOwner
539
- ? t("transcriptBubbles.me")
540
- : t("transcriptBubbles.them"))}
541
- </span>
650
+ <AvatarFallback
651
+ className={cn(
652
+ "text-[9px] font-semibold",
653
+ group.speaker.accentClass,
654
+ )}
655
+ >
656
+ {attendeeInitials(group.speaker.initialsSource)}
657
+ </AvatarFallback>
658
+ </Avatar>
659
+ <div className="flex min-h-6 items-center">
660
+ <span
661
+ className={cn(
662
+ "text-xs font-semibold leading-6",
663
+ group.speaker.isOwner
664
+ ? "text-primary"
665
+ : "text-foreground",
666
+ )}
667
+ >
668
+ {group.speaker.label ||
669
+ (group.speaker.isOwner
670
+ ? t("transcriptBubbles.me")
671
+ : t("transcriptBubbles.them"))}
672
+ </span>
673
+ </div>
542
674
  </div>
543
- </div>
675
+ )}
544
676
  <div className="space-y-1">
545
677
  {group.segments.map(({ seg, index }) => {
546
678
  return (
@@ -36,7 +36,9 @@ function wait(ms: number): Promise<void> {
36
36
  export interface CapturedTranscript {
37
37
  /** Speaker-labelled text, lines joined by blank lines. */
38
38
  text: string;
39
- /** Real whisper segments with verbatim timestamps. */
39
+ /** Whisper's verbatim timestamps where the engine reported them, else one
40
+ * synthesized segment per line — the mic-only engines report no timings,
41
+ * and a dropped line would lose its speaker along with its text. */
40
42
  segments: SourcedTranscriptSegment[];
41
43
  /** Source stored with `save-browser-transcript`. */
42
44
  source?: "web-speech" | "macos-native" | "whisper";
@@ -332,9 +334,14 @@ export async function startTranscriptionCapture(
332
334
  });
333
335
  };
334
336
 
337
+ // `source` reports the engine that actually produced this transcript, not
338
+ // the one we asked for: `startTranscriptionEngine` may have fallen back to
339
+ // mic-only macos-native. Omitting it made the server default to "whisper"
340
+ // and treat a mic-only capture as mixed mic + system audio.
335
341
  const captured = (): CapturedTranscript => ({
336
342
  text: transcriptFullText(lines),
337
343
  segments: transcriptSegments(lines),
344
+ source: engine,
338
345
  });
339
346
 
340
347
  let engine: TranscriptionEngine;
@@ -245,11 +245,48 @@ export function transcriptFullText(lines: TranscriptLine[]): string {
245
245
  .trim();
246
246
  }
247
247
 
248
- /** Flattened verbatim segments, as persisted alongside the text. */
248
+ /** Rough duration estimate for a line with no verbatim timing, matching the
249
+ * pacing `buildCaptionSegmentsFromText` uses server-side. */
250
+ function estimatedLineDurationMs(text: string): number {
251
+ const words = text.split(/\s+/).filter(Boolean).length || 1;
252
+ return Math.max(900, words * 420);
253
+ }
254
+
255
+ /** Flattened verbatim segments, as persisted alongside the text.
256
+ *
257
+ * A line with no verbatim timings still contributes one synthesized segment
258
+ * rather than being dropped: the mic-only fallback engines report no
259
+ * timestamps at all, and dropping the line loses its text and its `source`
260
+ * — and a `source`-less stored segment silently renders as "Them". Those
261
+ * engines report `startMs: null` on *every* line, so a synthesized segment
262
+ * continues from the previous line's end; anchoring each at its own
263
+ * null-coalesced 0 would stack the whole transcript on one instant and break
264
+ * ordering and timestamp seeking. */
249
265
  export function transcriptSegments(
250
266
  lines: TranscriptLine[],
251
267
  ): SourcedTranscriptSegment[] {
252
- return lines.flatMap((line) => line.segments);
268
+ const segments: SourcedTranscriptSegment[] = [];
269
+ let cursorMs = 0;
270
+ for (const line of lines) {
271
+ if (line.segments.length) {
272
+ segments.push(...line.segments);
273
+ cursorMs = line.segments.reduce(
274
+ (latest, segment) => Math.max(latest, segment.endMs),
275
+ cursorMs,
276
+ );
277
+ continue;
278
+ }
279
+ // `appendFinalTranscript` is the only path that yields an untimed line and
280
+ // it always pairs `segments: []` with `startMs: null`, so today this reads
281
+ // as `cursorMs`. Honouring a line's own stamp is for the shape the type
282
+ // still permits (see `historyLine` in overlays/live-transcript.tsx), and
283
+ // clamping to the cursor keeps a stale stamp from reordering the transcript.
284
+ const startMs = Math.max(line.startMs ?? cursorMs, cursorMs);
285
+ const endMs = startMs + estimatedLineDurationMs(line.text);
286
+ segments.push({ startMs, endMs, text: line.text, source: line.source });
287
+ cursorMs = endMs;
288
+ }
289
+ return segments;
253
290
  }
254
291
 
255
292
  /**
@@ -601,6 +601,9 @@ function renderFormPage(
601
601
  if (v) data[f.id] = parseInt(v);
602
602
  } else if (f.type === "scale") {
603
603
  data[f.id] = parseInt(el.querySelector(".slider").value);
604
+ } else if (f.type === "radio") {
605
+ var checked = el.querySelector('input[type="radio"]:checked');
606
+ if (checked && checked.value) data[f.id] = checked.value;
604
607
  } else {
605
608
  var input = el.querySelector("input, textarea, select");
606
609
  if (input && input.value) data[f.id] = input.value;
@@ -22,7 +22,9 @@ export default defineAction({
22
22
  includeSlides: z
23
23
  .enum(["true", "false"])
24
24
  .optional()
25
- .describe("Set to 'true' for full frontend deck payloads"),
25
+ .describe(
26
+ "Set to 'true' for full frontend deck payloads; omitted returns metadata only",
27
+ ),
26
28
  light: z
27
29
  .enum(["true", "false"])
28
30
  .optional()
@@ -84,6 +86,39 @@ export default defineAction({
84
86
  return { count: rows.length, decks: rows };
85
87
  }
86
88
 
89
+ if (args.includeSlides !== "true") {
90
+ // The deck body is an opaque JSON blob containing every slide's HTML.
91
+ // Metadata callers must opt into it explicitly; the frontend opens one
92
+ // deck at a time through get-deck instead of downloading every body.
93
+ const rows = await db
94
+ .select({
95
+ id: schema.decks.id,
96
+ title: schema.decks.title,
97
+ ownerEmail: schema.decks.ownerEmail,
98
+ designSystemId: schema.decks.designSystemId,
99
+ createdAt: schema.decks.createdAt,
100
+ updatedAt: schema.decks.updatedAt,
101
+ visibility: schema.decks.visibility,
102
+ })
103
+ .from(schema.decks)
104
+ .where(where)
105
+ .orderBy(desc(schema.decks.updatedAt));
106
+
107
+ return {
108
+ count: rows.length,
109
+ decks: rows.map((row) => ({
110
+ id: row.id,
111
+ title: row.title,
112
+ url: getDeckUrl(row.id),
113
+ visibility: row.visibility,
114
+ designSystemId: row.designSystemId ?? null,
115
+ createdByMe: ownerEmail ? row.ownerEmail === ownerEmail : false,
116
+ createdAt: row.createdAt,
117
+ updatedAt: row.updatedAt,
118
+ })),
119
+ };
120
+ }
121
+
87
122
  const rows = await db
88
123
  .select()
89
124
  .from(schema.decks)
@@ -965,11 +965,13 @@ function MultiSelectOutline({
965
965
  }
966
966
 
967
967
  /** Translucent rectangle drawn while marquee-dragging */
968
+ type MarqueeSelectionRect = { x: number; y: number; w: number; h: number };
969
+
968
970
  function MarqueeRect({
969
971
  rect,
970
972
  viewportRect,
971
973
  }: {
972
- rect: { x: number; y: number; w: number; h: number };
974
+ rect: MarqueeSelectionRect;
973
975
  viewportRect: DOMRect | null;
974
976
  }) {
975
977
  return (
@@ -1204,12 +1206,7 @@ export default function SlideEditor({
1204
1206
  /** Anchor rect for the floating chip (the slide canvas) */
1205
1207
  const [chipAnchorRect, setChipAnchorRect] = useState<DOMRect | null>(null);
1206
1208
  /** Active marquee rectangle (viewport coords). null = not dragging. */
1207
- const [marquee, setMarquee] = useState<{
1208
- x: number;
1209
- y: number;
1210
- w: number;
1211
- h: number;
1212
- } | null>(null);
1209
+ const [marquee, setMarquee] = useState<MarqueeSelectionRect | null>(null);
1213
1210
  const [activeAlignmentGuides, setActiveAlignmentGuides] = useState<{
1214
1211
  guides: SlideAlignmentGuide[];
1215
1212
  viewport: AlignmentGuideViewport;
@@ -1502,6 +1499,8 @@ export default function SlideEditor({
1502
1499
  }, [overflowInfo, slide.id, dims.width, dims.height]);
1503
1500
  /** Marquee origin (viewport coords). Set on pointerdown. */
1504
1501
  const marqueeOriginRef = useRef<{ x: number; y: number } | null>(null);
1502
+ /** Latest marquee geometry, readable by the stable window pointer handlers. */
1503
+ const marqueeRef = useRef<MarqueeSelectionRect | null>(null);
1505
1504
  /** Set right before placing a text box so the click event that follows the
1506
1505
  * placing pointerdown doesn't fall through to click-to-select/deselect
1507
1506
  * logic and steal focus back off the freshly created box. */
@@ -4432,7 +4431,12 @@ export default function SlideEditor({
4432
4431
  marqueeOriginRef.current = { x: e.clientX, y: e.clientY };
4433
4432
  marqueeAdditiveRef.current = e.shiftKey || e.metaKey || e.ctrlKey;
4434
4433
  marqueePrevSelectionRef.current = new Set(multiSelection);
4435
- setMarquee({ x: e.clientX, y: e.clientY, w: 0, h: 0 });
4434
+ const initialMarquee = { x: e.clientX, y: e.clientY, w: 0, h: 0 };
4435
+ marqueeRef.current = initialMarquee;
4436
+ setMarquee(initialMarquee);
4437
+ if (e.pointerId >= 0) {
4438
+ e.currentTarget.setPointerCapture(e.pointerId);
4439
+ }
4436
4440
 
4437
4441
  // Clear single-select feedback when starting a marquee on whitespace
4438
4442
  // (non-additive). Additive marquee preserves the existing selection.
@@ -4462,25 +4466,28 @@ export default function SlideEditor({
4462
4466
  ],
4463
4467
  );
4464
4468
 
4465
- // Window-level pointermove / pointerup so the drag still tracks if the
4466
- // pointer leaves the slide.
4469
+ // Keep these listeners stable while React re-renders the marquee overlay.
4470
+ // Re-attaching them whenever marquee state changes can lose a fast
4471
+ // pointermove/pointerup between the effect cleanup and re-install.
4467
4472
  useEffect(() => {
4468
- if (!marquee) return;
4469
4473
  const onMove = (e: PointerEvent) => {
4470
4474
  const origin = marqueeOriginRef.current;
4471
- if (!origin) return;
4475
+ if (!origin || !marqueeRef.current) return;
4472
4476
  const x = Math.min(origin.x, e.clientX);
4473
4477
  const y = Math.min(origin.y, e.clientY);
4474
4478
  const w = Math.abs(e.clientX - origin.x);
4475
4479
  const h = Math.abs(e.clientY - origin.y);
4476
- setMarquee({ x, y, w, h });
4480
+ const nextMarquee = { x, y, w, h };
4481
+ marqueeRef.current = nextMarquee;
4482
+ setMarquee(nextMarquee);
4477
4483
  };
4478
- const onUp = () => {
4484
+ const finish = (cancelled: boolean) => {
4479
4485
  const origin = marqueeOriginRef.current;
4480
- const current = marquee;
4486
+ const current = marqueeRef.current;
4481
4487
  marqueeOriginRef.current = null;
4488
+ marqueeRef.current = null;
4482
4489
  setMarquee(null);
4483
- if (!origin || !current) return;
4490
+ if (cancelled || !origin || !current) return;
4484
4491
 
4485
4492
  const slideContent = getSlideContent();
4486
4493
  if (!slideContent) return;
@@ -4518,13 +4525,18 @@ export default function SlideEditor({
4518
4525
 
4519
4526
  applyMultiSelection(hits);
4520
4527
  };
4528
+
4529
+ const onUp = () => finish(false);
4530
+ const onCancel = () => finish(true);
4521
4531
  window.addEventListener("pointermove", onMove);
4522
4532
  window.addEventListener("pointerup", onUp);
4533
+ window.addEventListener("pointercancel", onCancel);
4523
4534
  return () => {
4524
4535
  window.removeEventListener("pointermove", onMove);
4525
4536
  window.removeEventListener("pointerup", onUp);
4537
+ window.removeEventListener("pointercancel", onCancel);
4526
4538
  };
4527
- }, [marquee, getSlideContent, applyMultiSelection]);
4539
+ }, [getSlideContent, applyMultiSelection]);
4528
4540
 
4529
4541
  /** Send the current selection to the agent chat composer */
4530
4542
  const sendSelectionToAgent = useCallback(() => {
@@ -940,7 +940,7 @@ export function deriveInverseOp(
940
940
  }
941
941
 
942
942
  /**
943
- * Fetch the deck list. Returns `null` on any failure (network error, non-2xx
943
+ * Fetch the deck metadata list. Returns `null` on any failure (network error, non-2xx
944
944
  * response) so callers can distinguish "authoritative empty list" from
945
945
  * "couldn't reach the server" — wiping local state on a transient failure
946
946
  * kicks the user out of the editor and shows the "Create your first deck"
@@ -951,7 +951,7 @@ async function fetchDecksFromAPI(): Promise<Deck[] | null> {
951
951
  try {
952
952
  const result = await callAction<DeckListActionResult>(
953
953
  "list-decks",
954
- { includeSlides: "true" },
954
+ { light: "true" },
955
955
  { method: "GET" },
956
956
  );
957
957
  if (!Array.isArray(result?.decks)) {
@@ -1076,12 +1076,23 @@ export async function includeOpenDeckIfMissing(
1076
1076
  async function fetchDecksForCurrentRoute(): Promise<Deck[] | null> {
1077
1077
  const currentOpenDeckId = currentOpenDeckIdFromWindow();
1078
1078
  const loaded = await fetchDecksFromAPI();
1079
- if (loaded !== null) {
1080
- return includeOpenDeckIfMissing(loaded, currentOpenDeckId);
1079
+ if (loaded === null) {
1080
+ if (!currentOpenDeckId) return null;
1081
+ const directDeck = await fetchDeckFromAPI(currentOpenDeckId);
1082
+ return directDeck ? [directDeck] : null;
1081
1083
  }
1082
- if (!currentOpenDeckId) return null;
1084
+ if (!currentOpenDeckId) return loaded;
1085
+
1086
+ // The list is intentionally metadata-only. Hydrate just the deck the user
1087
+ // opened so the editor gets full slide content without making the home page
1088
+ // download every deck body.
1083
1089
  const directDeck = await fetchDeckFromAPI(currentOpenDeckId);
1084
- return directDeck ? [directDeck] : null;
1090
+ if (!directDeck) return loaded;
1091
+ const index = loaded.findIndex((deck) => deck.id === currentOpenDeckId);
1092
+ if (index < 0) return [...loaded, directDeck];
1093
+ const next = [...loaded];
1094
+ next[index] = directDeck;
1095
+ return next;
1085
1096
  }
1086
1097
 
1087
1098
  async function deleteDeckFromAPI(id: string): Promise<void> {