@agent-native/core 0.157.5 → 0.157.7

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 (109) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/agent-native.config.ts +5 -0
  3. package/corpus/templates/analytics/netlify.toml +1 -0
  4. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +23 -5
  5. package/corpus/templates/analytics/server/plugins/agent-chat.ts +4 -0
  6. package/corpus/templates/assets/netlify.toml +3 -0
  7. package/corpus/templates/brain/netlify.toml +3 -0
  8. package/corpus/templates/calendar/agent-native.config.ts +5 -0
  9. package/corpus/templates/calendar/app/components/layout/AppLayout.tsx +4 -1
  10. package/corpus/templates/calendar/netlify.toml +3 -0
  11. package/corpus/templates/chat/agent-native.config.ts +5 -0
  12. package/corpus/templates/chat/netlify.toml +3 -0
  13. package/corpus/templates/clips/actions/create-meeting.ts +19 -1
  14. package/corpus/templates/clips/actions/lib/meeting-content.ts +28 -0
  15. package/corpus/templates/clips/actions/list-meetings.ts +33 -11
  16. package/corpus/templates/clips/app/components/meetings/bullet-link.tsx +48 -25
  17. package/corpus/templates/clips/app/components/meetings/canvas-editor.tsx +7 -6
  18. package/corpus/templates/clips/app/components/meetings/transcript-bubbles.tsx +388 -178
  19. package/corpus/templates/clips/app/components/player/transcript-panel.tsx +7 -13
  20. package/corpus/templates/clips/app/components/transcript/transcript-segment-row.tsx +98 -0
  21. package/corpus/templates/clips/app/i18n/en-US.ts +5 -0
  22. package/corpus/templates/clips/app/lib/meeting-history-query.ts +11 -0
  23. package/corpus/templates/clips/app/lib/public-meeting.ts +4 -0
  24. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +49 -31
  25. package/corpus/templates/clips/app/routes/_app.meetings._index.tsx +47 -21
  26. package/corpus/templates/clips/app/routes/share.meeting.$meetingId.tsx +15 -1
  27. package/corpus/templates/clips/netlify.toml +8 -4
  28. package/corpus/templates/clips/server/routes/api/public-meeting.get.ts +10 -0
  29. package/corpus/templates/clips/shared/transcript-segments.ts +20 -2
  30. package/corpus/templates/content/netlify.toml +3 -0
  31. package/corpus/templates/design/app/i18n-data.ts +2 -0
  32. package/corpus/templates/design/netlify.toml +3 -0
  33. package/corpus/templates/dispatch/netlify.toml +3 -0
  34. package/corpus/templates/factory/netlify.toml +3 -0
  35. package/corpus/templates/forms/netlify.toml +3 -0
  36. package/corpus/templates/macros/netlify.toml +3 -0
  37. package/corpus/templates/mail/agent-native.config.ts +5 -0
  38. package/corpus/templates/mail/netlify.toml +3 -0
  39. package/corpus/templates/plan/netlify.toml +3 -0
  40. package/corpus/templates/slides/netlify.toml +3 -0
  41. package/dist/agent/harness/acp-adapter.js +17 -2
  42. package/dist/agent/harness/ai-sdk-adapter.js +14 -3
  43. package/dist/agent/harness/background.js +10 -5
  44. package/dist/agent/harness/hosted.d.ts +20 -0
  45. package/dist/agent/harness/hosted.js +100 -0
  46. package/dist/agent/harness/runner.js +100 -77
  47. package/dist/agent/observational-memory/observer.js +12 -0
  48. package/dist/agent/production-agent.d.ts +3 -0
  49. package/dist/agent/production-agent.js +74 -4
  50. package/dist/agent/thread-data-builder.d.ts +12 -0
  51. package/dist/agent/thread-data-builder.js +23 -0
  52. package/dist/agent/types.d.ts +6 -0
  53. package/dist/client/AgentPanel.d.ts +5 -1
  54. package/dist/client/AgentPanel.js +113 -17
  55. package/dist/client/AgentTaskCard.d.ts +2 -2
  56. package/dist/client/AgentTaskCard.js +8 -22
  57. package/dist/client/AssistantChat.d.ts +8 -0
  58. package/dist/client/AssistantChat.js +106 -59
  59. package/dist/client/MultiTabAssistantChat.js +50 -6
  60. package/dist/client/NewWorkspaceAppFlow.js +7 -1
  61. package/dist/client/agent-chat-adapter.d.ts +6 -0
  62. package/dist/client/agent-chat-adapter.js +36 -8
  63. package/dist/client/app-chat-sidebar.d.ts +5 -0
  64. package/dist/client/app-chat-sidebar.js +53 -9
  65. package/dist/client/chat/composer-draft.d.ts +4 -0
  66. package/dist/client/chat/composer-draft.js +63 -0
  67. package/dist/client/chat-first/apps-rail.js +10 -1
  68. package/dist/client/chat-first/primary-nav.js +10 -5
  69. package/dist/client/frame-protocol.d.ts +1 -0
  70. package/dist/client/hooks/index.d.ts +1 -1
  71. package/dist/client/hooks/index.js +1 -1
  72. package/dist/client/i18n.js +5 -2
  73. package/dist/client/progress/RunsTray.js +129 -16
  74. package/dist/client/settings/SettingsRow.js +1 -1
  75. package/dist/client/use-chat-threads.js +11 -6
  76. package/dist/collab/routes.d.ts +1 -1
  77. package/dist/collab/struct-routes.d.ts +1 -1
  78. package/dist/config.d.ts +11 -0
  79. package/dist/config.js +52 -0
  80. package/dist/deploy/build.js +28 -11
  81. package/dist/extensions/actions.js +22 -1
  82. package/dist/hosted-harness/actions/get-hosted-harness-config.d.ts +2 -0
  83. package/dist/hosted-harness/actions/get-hosted-harness-config.js +13 -0
  84. package/dist/hosted-harness/actions/set-hosted-harness-enabled.d.ts +4 -0
  85. package/dist/hosted-harness/actions/set-hosted-harness-enabled.js +32 -0
  86. package/dist/localization/default-messages.d.ts +4 -1
  87. package/dist/localization/default-messages.js +5 -2
  88. package/dist/observability/routes.d.ts +5 -5
  89. package/dist/provider-api/actions/custom-provider-registration.d.ts +6 -6
  90. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  91. package/dist/resources/handlers.d.ts +1 -1
  92. package/dist/secrets/routes.d.ts +9 -9
  93. package/dist/server/action-discovery.js +12 -0
  94. package/dist/server/agent-chat-plugin.js +24 -4
  95. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  96. package/dist/server/agent-teams.d.ts +20 -12
  97. package/dist/server/agent-teams.js +69 -35
  98. package/dist/server/hosted-harness-policy.d.ts +17 -0
  99. package/dist/server/hosted-harness-policy.js +38 -0
  100. package/dist/server/realtime-token.d.ts +1 -1
  101. package/dist/server/request-context.d.ts +4 -0
  102. package/dist/server/transcribe-voice.d.ts +1 -1
  103. package/dist/styles/agent-native.css +39 -0
  104. package/dist/templates/chat/agent-native.config.ts +5 -0
  105. package/dist/templates/chat/netlify.toml +3 -0
  106. package/dist/vite/action-types-plugin.js +8 -0
  107. package/package.json +4 -2
  108. package/src/templates/chat/agent-native.config.ts +5 -0
  109. package/src/templates/chat/netlify.toml +3 -0
@@ -6,18 +6,25 @@ import {
6
6
  IconSearch,
7
7
  IconX,
8
8
  } from "@tabler/icons-react";
9
+ import type { KeyboardEvent, ReactNode } from "react";
9
10
  import { useEffect, useMemo, useRef, useState } from "react";
10
11
 
12
+ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
11
13
  import { Button } from "@/components/ui/button";
12
14
  import { Input } from "@/components/ui/input";
13
15
  import {
14
16
  Tooltip,
15
17
  TooltipContent,
16
- TooltipProvider,
17
18
  TooltipTrigger,
18
19
  } from "@/components/ui/tooltip";
19
20
  import { cn } from "@/lib/utils";
20
21
 
22
+ import { TranscriptSegmentRow } from "../transcript/transcript-segment-row";
23
+ import {
24
+ attendeeInitials,
25
+ type AttendeeStackParticipant,
26
+ } from "./attendee-stack";
27
+
21
28
  export interface TranscriptSegment {
22
29
  startMs: number;
23
30
  endMs?: number;
@@ -29,25 +36,143 @@ export interface TranscriptSegment {
29
36
  interface TranscriptBubblesProps {
30
37
  segments: TranscriptSegment[];
31
38
  isLive: boolean;
39
+ participants?: AttendeeStackParticipant[];
40
+ ownerEmail?: string | null;
32
41
  /**
33
42
  * Imperative ref hook: parent can scroll a particular segment into view.
34
43
  * Receives a function (segmentIndex) => void.
35
44
  */
36
45
  registerScrollTo?: (fn: (segmentIndex: number) => void) => void;
37
- }
38
-
39
- function formatTimestamp(ms: number): string {
40
- const total = Math.floor(ms / 1000);
41
- const m = Math.floor(total / 60);
42
- const s = total % 60;
43
- return `${m}:${s.toString().padStart(2, "0")}`;
46
+ /** Rendered at the left of the header row. Omit for no title (the header
47
+ * then shows only the search trigger, e.g. the compact share-page use). */
48
+ title?: ReactNode;
49
+ /** Rendered in the header, before the search trigger (e.g. a copy button). */
50
+ headerActions?: ReactNode;
44
51
  }
45
52
 
46
53
  interface BubbleGroup {
47
- source: "mic" | "system";
54
+ speaker: SpeakerIdentity;
48
55
  segments: { seg: TranscriptSegment; index: number }[];
49
56
  }
50
57
 
58
+ export interface SpeakerIdentity {
59
+ key: string;
60
+ label: string | null;
61
+ initialsSource: AttendeeStackParticipant | string;
62
+ isOwner: boolean;
63
+ accentClass: string;
64
+ }
65
+
66
+ const SPEAKER_ACCENTS = [
67
+ "bg-accent text-accent-foreground",
68
+ "bg-secondary text-secondary-foreground",
69
+ "bg-muted text-foreground",
70
+ ] as const;
71
+
72
+ const OWNER_ACCENT = "bg-highlight/10 text-primary";
73
+
74
+ function normalizeSpeaker(value: string): string {
75
+ return value.trim().toLowerCase().replace(/\s+/g, " ");
76
+ }
77
+
78
+ // Exported for regression testing — see transcript-bubbles.test.ts. These
79
+ // are pure functions with no dependency on the component itself.
80
+ export function findParticipant(
81
+ speaker: string | null | undefined,
82
+ participants: AttendeeStackParticipant[],
83
+ ): AttendeeStackParticipant | undefined {
84
+ const normalizedSpeaker = speaker ? normalizeSpeaker(speaker) : "";
85
+ if (!normalizedSpeaker) return undefined;
86
+ return participants.find((participant) => {
87
+ const name = participant.name ? normalizeSpeaker(participant.name) : "";
88
+ const email = normalizeSpeaker(participant.email);
89
+ return normalizedSpeaker === name || normalizedSpeaker === email;
90
+ });
91
+ }
92
+
93
+ export function resolveParticipantForSpeaker(
94
+ source: "mic" | "system",
95
+ participants: AttendeeStackParticipant[],
96
+ ownerEmail?: string | null,
97
+ ): AttendeeStackParticipant | undefined {
98
+ // `undefined` and `null` are different signals here, not two spellings of
99
+ // "no owner" — a caller that never threads owner data through at all
100
+ // passes `undefined` (the only case where guessing via isOrganizer is
101
+ // acceptable, e.g. legacy data with no recorded owner). The public share
102
+ // page passes an explicit `null` when it *knows* the owner but withholds
103
+ // them for privacy (they aren't a public participant) — that still means
104
+ // "don't guess a different specific identity," it just can't resolve to a
105
+ // name, so mic segments fall through to the generic "Me" label instead.
106
+ const ownerParticipant =
107
+ ownerEmail === undefined
108
+ ? participants.find((participant) => participant.isOrganizer)
109
+ : ownerEmail
110
+ ? findParticipant(ownerEmail, participants)
111
+ : undefined;
112
+
113
+ if (source === "mic") return ownerParticipant;
114
+ if (!ownerParticipant) return undefined;
115
+
116
+ // Generic Them/System segments can only be resolved from meeting metadata
117
+ // when there is exactly one possible remote participant. Do not guess in a
118
+ // group meeting until transcript ingestion stores a stable speaker id.
119
+ const otherParticipants = participants.filter(
120
+ (participant) =>
121
+ normalizeSpeaker(participant.email) !==
122
+ normalizeSpeaker(ownerParticipant.email),
123
+ );
124
+ return otherParticipants.length === 1 ? otherParticipants[0] : undefined;
125
+ }
126
+
127
+ function accentForSpeaker(key: string, isOwner: boolean): string {
128
+ if (isOwner) return OWNER_ACCENT;
129
+
130
+ let hash = 0;
131
+ for (const character of key) {
132
+ hash = (hash * 31 + character.charCodeAt(0)) | 0;
133
+ }
134
+ return SPEAKER_ACCENTS[Math.abs(hash) % SPEAKER_ACCENTS.length];
135
+ }
136
+
137
+ export function resolveSpeaker(
138
+ segment: TranscriptSegment,
139
+ participants: AttendeeStackParticipant[],
140
+ ownerEmail?: string | null,
141
+ ): SpeakerIdentity {
142
+ const source = segment.source === "mic" ? "mic" : "system";
143
+ const rawSpeaker = segment.speaker?.trim();
144
+ const participant =
145
+ findParticipant(segment.speaker, participants) ||
146
+ resolveParticipantForSpeaker(source, participants, ownerEmail);
147
+ const participantName = participant?.name?.trim();
148
+ 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.
153
+ const isGenericPlaceholderLabel =
154
+ !!resolvedLabel &&
155
+ (source === "mic"
156
+ ? /^(me|self|you)$/i.test(resolvedLabel)
157
+ : /^them$/i.test(resolvedLabel));
158
+ const label = isGenericPlaceholderLabel
159
+ ? null
160
+ : participantName || rawSpeaker || null;
161
+ const key =
162
+ source === "mic"
163
+ ? source
164
+ : participant?.email ||
165
+ (rawSpeaker ? `speaker:${normalizeSpeaker(rawSpeaker)}` : source);
166
+
167
+ return {
168
+ key,
169
+ label,
170
+ initialsSource: participant ?? label ?? (source === "mic" ? "Me" : "Them"),
171
+ isOwner: source === "mic",
172
+ accentClass: accentForSpeaker(key, source === "mic"),
173
+ };
174
+ }
175
+
51
176
  // Splits `text` into plain/matched runs for a case-insensitive substring
52
177
  // highlight. Returns the original text as a single run when there's no query
53
178
  // or no match, so callers can render uniformly either way.
@@ -73,16 +198,19 @@ function highlightRuns(
73
198
  return runs.length ? runs : [{ text, match: false }];
74
199
  }
75
200
 
76
- function groupConsecutive(segments: TranscriptSegment[]): BubbleGroup[] {
201
+ function groupConsecutive(
202
+ segments: TranscriptSegment[],
203
+ participants: AttendeeStackParticipant[],
204
+ ownerEmail?: string | null,
205
+ ): BubbleGroup[] {
77
206
  const groups: BubbleGroup[] = [];
78
207
  segments.forEach((seg, index) => {
79
- // Default unknown source to "system" (Them) — Granola convention.
80
- const source: "mic" | "system" = seg.source === "mic" ? "mic" : "system";
208
+ const speaker = resolveSpeaker(seg, participants, ownerEmail);
81
209
  const last = groups[groups.length - 1];
82
- if (last && last.source === source) {
210
+ if (last && last.speaker.key === speaker.key) {
83
211
  last.segments.push({ seg, index });
84
212
  } else {
85
- groups.push({ source, segments: [{ seg, index }] });
213
+ groups.push({ speaker, segments: [{ seg, index }] });
86
214
  }
87
215
  });
88
216
  return groups;
@@ -91,7 +219,11 @@ function groupConsecutive(segments: TranscriptSegment[]): BubbleGroup[] {
91
219
  export function TranscriptBubbles({
92
220
  segments,
93
221
  isLive,
222
+ participants = [],
223
+ ownerEmail,
94
224
  registerScrollTo,
225
+ title,
226
+ headerActions,
95
227
  }: TranscriptBubblesProps) {
96
228
  const t = useT();
97
229
  const containerRef = useRef<HTMLDivElement>(null);
@@ -104,8 +236,13 @@ export function TranscriptBubbles({
104
236
  const [searchOpen, setSearchOpen] = useState(false);
105
237
  const [searchQuery, setSearchQuery] = useState("");
106
238
  const [matchCursor, setMatchCursor] = useState(0);
239
+ const [keyboardSegmentIndex, setKeyboardSegmentIndex] = useState(0);
240
+ const [highlightedIndex, setHighlightedIndex] = useState<number | null>(null);
107
241
 
108
- const groups = useMemo(() => groupConsecutive(segments), [segments]);
242
+ const groups = useMemo(
243
+ () => groupConsecutive(segments, participants, ownerEmail),
244
+ [segments, participants, ownerEmail],
245
+ );
109
246
 
110
247
  const normalizedQuery = searchQuery.trim().toLowerCase();
111
248
  const matchIndexes = useMemo(() => {
@@ -161,6 +298,22 @@ export function TranscriptBubbles({
161
298
  );
162
299
  };
163
300
 
301
+ const focusSegment = (index: number) => {
302
+ const nextIndex = Math.max(0, Math.min(index, segments.length - 1));
303
+ setKeyboardSegmentIndex(nextIndex);
304
+ segmentRefs.current[nextIndex]?.focus();
305
+ };
306
+
307
+ const handleSegmentKeyDown = (
308
+ event: KeyboardEvent<HTMLDivElement>,
309
+ index: number,
310
+ ) => {
311
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
312
+ event.preventDefault();
313
+ focusSegment(index + (event.key === "ArrowDown" ? 1 : -1));
314
+ }
315
+ };
316
+
164
317
  useEffect(() => {
165
318
  const el = containerRef.current;
166
319
  if (!el) return;
@@ -179,17 +332,18 @@ export function TranscriptBubbles({
179
332
  }
180
333
  }, [isLive, segments.length]);
181
334
 
182
- // Shared imperative scroll-and-flash, used by both the parent's bullet-jump
183
- // wiring (registerScrollTo) and in-panel search navigation below.
335
+ // Shared scroll-and-flash, used by both the parent's bullet-jump wiring
336
+ // (registerScrollTo) and in-panel search navigation below. Highlight state
337
+ // lives in React (not classList) so TranscriptSegmentRow can suppress its
338
+ // own hover styling while the flash is active — see `highlighted` there.
184
339
  const scrollToAndFlash = useRef((segmentIndex: number) => {
185
340
  const node = segmentRefs.current[segmentIndex];
186
341
  if (!node) return;
187
342
  node.scrollIntoView({ behavior: "smooth", block: "center" });
188
- // Yellow-flash highlight for ~1.5s.
189
- node.classList.add("ring-2", "ring-yellow-400/70", "bg-yellow-400/10");
343
+ setHighlightedIndex(segmentIndex);
190
344
  if (flashTimeoutRef.current) window.clearTimeout(flashTimeoutRef.current);
191
345
  flashTimeoutRef.current = window.setTimeout(() => {
192
- node.classList.remove("ring-2", "ring-yellow-400/70", "bg-yellow-400/10");
346
+ setHighlightedIndex(null);
193
347
  }, 1500);
194
348
  }).current;
195
349
 
@@ -202,181 +356,237 @@ export function TranscriptBubbles({
202
356
  registerScrollTo(scrollToAndFlash);
203
357
  }, [registerScrollTo, scrollToAndFlash]);
204
358
 
205
- if (segments.length === 0) {
206
- if (isLive) {
207
- return (
208
- <div className="flex flex-col items-center justify-center h-full text-center text-sm text-muted-foreground gap-2">
209
- <span className="relative flex h-2 w-2">
210
- <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-60" />
211
- <span className="relative inline-flex h-2 w-2 rounded-full bg-red-500" />
212
- </span>
213
- {t("transcriptBubbles.listening")}
214
- </div>
215
- );
216
- }
217
- return (
218
- <div className="flex flex-col items-center justify-center h-full text-center text-sm text-muted-foreground gap-2 px-6">
219
- <IconNotes className="h-6 w-6 text-muted-foreground/50" />
220
- <span>{t("transcriptBubbles.noTranscript")}</span>
221
- <span className="text-xs">
222
- {t("transcriptBubbles.liveTranscriptDescription")}
223
- </span>
224
- </div>
225
- );
226
- }
359
+ useEffect(() => {
360
+ return () => {
361
+ if (flashTimeoutRef.current) window.clearTimeout(flashTimeoutRef.current);
362
+ };
363
+ }, []);
227
364
 
228
365
  const activeMatchIndex = matchIndexes.length
229
366
  ? matchIndexes[matchCursor % matchIndexes.length]
230
367
  : null;
231
368
 
232
369
  return (
233
- <TooltipProvider delayDuration={200}>
234
- <div className="flex shrink-0 items-center justify-end gap-1.5 border-b border-border px-2 py-1.5">
235
- {searchOpen ? (
236
- <>
237
- <Input
238
- ref={searchInputRef}
239
- value={searchQuery}
240
- onChange={(e) => setSearchQuery(e.target.value)}
241
- onKeyDown={(e) => {
242
- if (e.key === "Escape") {
243
- e.preventDefault();
244
- closeSearch();
245
- } else if (e.key === "Enter") {
246
- e.preventDefault();
247
- goToMatch(e.shiftKey ? -1 : 1);
248
- }
249
- }}
250
- onBlur={() => {
251
- if (!searchQuery.trim()) closeSearch();
252
- }}
253
- placeholder={t("transcriptBubbles.searchPlaceholder")}
254
- className="h-7 flex-1 text-xs"
255
- />
256
- {normalizedQuery && (
257
- <span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
258
- {matchIndexes.length > 0
259
- ? t("transcriptBubbles.searchMatchCount", {
260
- current: (matchCursor % matchIndexes.length) + 1,
261
- total: matchIndexes.length,
262
- })
263
- : t("transcriptBubbles.searchNoMatches")}
264
- </span>
265
- )}
266
- <Button
267
- size="icon"
268
- variant="ghost"
269
- className="h-7 w-7 shrink-0 cursor-pointer"
270
- disabled={!matchIndexes.length}
271
- aria-label={t("transcriptBubbles.searchPrevMatch")}
272
- onClick={() => goToMatch(-1)}
273
- >
274
- <IconChevronUp className="h-3.5 w-3.5" />
275
- </Button>
276
- <Button
277
- size="icon"
278
- variant="ghost"
279
- className="h-7 w-7 shrink-0 cursor-pointer"
280
- disabled={!matchIndexes.length}
281
- aria-label={t("transcriptBubbles.searchNextMatch")}
282
- onClick={() => goToMatch(1)}
283
- >
284
- <IconChevronDown className="h-3.5 w-3.5" />
285
- </Button>
370
+ <div className="flex h-full flex-col">
371
+ {/* Primary header — title/actions never get displaced by search, so the
372
+ panel's identity stays put and this row's height always matches
373
+ sibling panels. The search UI is a second row that only exists
374
+ while search is actually open, not permanent chrome. */}
375
+ <div
376
+ className={cn(
377
+ "flex h-11 shrink-0 items-center gap-1.5 px-4",
378
+ // Only the last header row gets the divider below it — when
379
+ // search is open that's the search row, not this one, so the two
380
+ // read as one frame instead of stacked, separate boxes.
381
+ !searchOpen && "border-b border-border",
382
+ )}
383
+ >
384
+ <div className="flex flex-1 items-center gap-1.5 text-xs font-medium">
385
+ {title}
386
+ </div>
387
+ {headerActions}
388
+ <Tooltip>
389
+ <TooltipTrigger asChild>
286
390
  <Button
287
391
  size="icon"
288
392
  variant="ghost"
289
- className="h-7 w-7 shrink-0 cursor-pointer"
290
- aria-label={t("transcriptBubbles.searchClose")}
291
- onClick={closeSearch}
393
+ aria-pressed={searchOpen}
394
+ className={cn(
395
+ "h-7 w-7 shrink-0 cursor-pointer",
396
+ searchOpen && "bg-accent",
397
+ )}
398
+ aria-label={
399
+ searchOpen
400
+ ? t("transcriptBubbles.searchClose")
401
+ : t("transcriptBubbles.searchTranscript")
402
+ }
403
+ // Without this, clicking here while the input is focused blurs
404
+ // it first (onBlur may already auto-close on an empty query),
405
+ // then this handler runs against state that just changed out
406
+ // from under it — sometimes reopening what onBlur just closed.
407
+ // Keeping focus on the input means blur never fires from this
408
+ // click at all, so there's nothing left to race.
409
+ onMouseDown={(e) => e.preventDefault()}
410
+ onClick={() => (searchOpen ? closeSearch() : setSearchOpen(true))}
292
411
  >
293
- <IconX className="h-3.5 w-3.5" />
412
+ <IconSearch className="h-3.5 w-3.5" />
294
413
  </Button>
295
- </>
296
- ) : (
414
+ </TooltipTrigger>
415
+ <TooltipContent>
416
+ {searchOpen
417
+ ? t("transcriptBubbles.searchClose")
418
+ : t("transcriptBubbles.searchTranscript")}
419
+ </TooltipContent>
420
+ </Tooltip>
421
+ </div>
422
+
423
+ {searchOpen && (
424
+ <div className="flex shrink-0 items-center gap-1.5 border-b border-border px-4 py-1.5">
425
+ <Input
426
+ ref={searchInputRef}
427
+ value={searchQuery}
428
+ onChange={(e) => setSearchQuery(e.target.value)}
429
+ onKeyDown={(e) => {
430
+ if (e.key === "Escape") {
431
+ e.preventDefault();
432
+ closeSearch();
433
+ } else if (e.key === "Enter") {
434
+ e.preventDefault();
435
+ goToMatch(e.shiftKey ? -1 : 1);
436
+ }
437
+ }}
438
+ onBlur={() => {
439
+ if (!searchQuery.trim()) closeSearch();
440
+ }}
441
+ placeholder={t("transcriptBubbles.searchPlaceholder")}
442
+ className="h-7 flex-1 text-xs"
443
+ />
444
+ {normalizedQuery && (
445
+ <span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
446
+ {matchIndexes.length > 0
447
+ ? t("transcriptBubbles.searchMatchCount", {
448
+ current: (matchCursor % matchIndexes.length) + 1,
449
+ total: matchIndexes.length,
450
+ })
451
+ : t("transcriptBubbles.searchNoMatches")}
452
+ </span>
453
+ )}
297
454
  <Button
298
455
  size="icon"
299
456
  variant="ghost"
300
457
  className="h-7 w-7 shrink-0 cursor-pointer"
301
- aria-label={t("transcriptBubbles.searchTranscript")}
302
- onClick={() => setSearchOpen(true)}
458
+ disabled={!matchIndexes.length}
459
+ aria-label={t("transcriptBubbles.searchPrevMatch")}
460
+ onClick={() => goToMatch(-1)}
303
461
  >
304
- <IconSearch className="h-3.5 w-3.5" />
462
+ <IconChevronUp className="h-3.5 w-3.5" />
463
+ </Button>
464
+ <Button
465
+ size="icon"
466
+ variant="ghost"
467
+ className="h-7 w-7 shrink-0 cursor-pointer"
468
+ disabled={!matchIndexes.length}
469
+ aria-label={t("transcriptBubbles.searchNextMatch")}
470
+ onClick={() => goToMatch(1)}
471
+ >
472
+ <IconChevronDown className="h-3.5 w-3.5" />
473
+ </Button>
474
+ <Button
475
+ size="icon"
476
+ variant="ghost"
477
+ className="h-7 w-7 shrink-0 cursor-pointer"
478
+ aria-label={t("transcriptBubbles.searchClose")}
479
+ onClick={closeSearch}
480
+ >
481
+ <IconX className="h-3.5 w-3.5" />
305
482
  </Button>
306
- )}
307
- </div>
308
- <div ref={containerRef} className="flex-1 overflow-y-auto p-4">
309
- <div className="mx-auto max-w-3xl space-y-6">
310
- {groups.map((group, gi) => {
311
- const isMe = group.source === "mic";
312
- return (
313
- <section key={gi} className="space-y-1.5">
314
- <div className="flex items-center gap-2 px-1 text-[11px] font-medium tracking-wide text-muted-foreground">
315
- <span
316
- aria-hidden="true"
317
- className={cn(
318
- "h-1.5 w-1.5 rounded-full",
319
- isMe ? "bg-primary" : "bg-muted-foreground/50",
320
- )}
321
- />
322
- {isMe
323
- ? t("transcriptBubbles.me")
324
- : t("transcriptBubbles.them")}
325
- </div>
326
- <div className="space-y-0.5">
327
- {group.segments.map(({ seg, index }) => {
328
- return (
329
- <Tooltip key={index}>
330
- <TooltipTrigger asChild>
331
- <div
332
- ref={(el) => {
333
- segmentRefs.current[index] = el;
334
- }}
335
- className="group/segment -mx-1 rounded-md px-1 py-1 text-left text-sm leading-relaxed text-foreground transition-colors"
336
- >
337
- {seg.speaker && !isMe && (
338
- <span className="me-2 text-[11px] font-medium text-muted-foreground">
339
- {seg.speaker}
340
- </span>
341
- )}
342
- <span className="whitespace-pre-wrap">
343
- {normalizedQuery
344
- ? highlightRuns(seg.text, normalizedQuery).map(
345
- (run, ri) =>
346
- run.match ? (
347
- <mark
348
- key={ri}
349
- className={cn(
350
- "rounded-sm bg-yellow-400/70 text-foreground",
351
- index === activeMatchIndex &&
352
- "bg-yellow-400 ring-1 ring-yellow-600",
353
- )}
354
- >
355
- {run.text}
356
- </mark>
357
- ) : (
358
- <span key={ri}>{run.text}</span>
359
- ),
360
- )
361
- : seg.text}
362
- </span>
363
- </div>
364
- </TooltipTrigger>
365
- <TooltipContent side="right">
366
- <span className="font-mono tabular-nums text-[11px]">
367
- {formatTimestamp(seg.startMs)}
368
- </span>
369
- </TooltipContent>
370
- </Tooltip>
371
- );
372
- })}
373
- </div>
374
- </section>
375
- );
376
- })}
377
- <div ref={liveEndRef} />
378
483
  </div>
379
- </div>
380
- </TooltipProvider>
484
+ )}
485
+ {segments.length === 0 ? (
486
+ isLive ? (
487
+ <div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center text-sm text-muted-foreground">
488
+ <span className="relative flex h-2 w-2">
489
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-destructive opacity-60" />
490
+ <span className="relative inline-flex h-2 w-2 rounded-full bg-destructive" />
491
+ </span>
492
+ {t("transcriptBubbles.listening")}
493
+ </div>
494
+ ) : (
495
+ <div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center text-sm text-muted-foreground">
496
+ <IconNotes className="h-6 w-6 text-muted-foreground/50" />
497
+ <span>{t("transcriptBubbles.noTranscript")}</span>
498
+ <span className="text-xs">
499
+ {t("transcriptBubbles.liveTranscriptDescription")}
500
+ </span>
501
+ </div>
502
+ )
503
+ ) : (
504
+ <div ref={containerRef} className="flex-1 overflow-y-auto p-4">
505
+ <div className="mx-auto max-w-3xl space-y-4">
506
+ {groups.map((group, gi) => {
507
+ return (
508
+ <section
509
+ key={`${group.speaker.key}:${gi}`}
510
+ className="space-y-0.5"
511
+ >
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
520
+ className={cn(
521
+ "text-[9px] font-semibold",
522
+ group.speaker.accentClass,
523
+ )}
524
+ >
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>
542
+ </div>
543
+ </div>
544
+ <div className="space-y-1">
545
+ {group.segments.map(({ seg, index }) => {
546
+ return (
547
+ <TranscriptSegmentRow
548
+ key={index}
549
+ startMs={seg.startMs}
550
+ highlighted={index === highlightedIndex}
551
+ tabIndex={index === keyboardSegmentIndex ? 0 : -1}
552
+ onKeyDown={(event) =>
553
+ handleSegmentKeyDown(event, index)
554
+ }
555
+ segmentRef={(el) => {
556
+ segmentRefs.current[index] = el;
557
+ }}
558
+ className="hover:bg-accent/30"
559
+ >
560
+ {normalizedQuery
561
+ ? highlightRuns(seg.text, normalizedQuery).map(
562
+ (run, ri) =>
563
+ run.match ? (
564
+ <mark
565
+ key={ri}
566
+ className={cn(
567
+ "rounded-sm bg-yellow-400/70 text-foreground",
568
+ index === activeMatchIndex &&
569
+ "bg-yellow-400 ring-1 ring-yellow-600",
570
+ )}
571
+ >
572
+ {run.text}
573
+ </mark>
574
+ ) : (
575
+ <span key={ri}>{run.text}</span>
576
+ ),
577
+ )
578
+ : seg.text}
579
+ </TranscriptSegmentRow>
580
+ );
581
+ })}
582
+ </div>
583
+ </section>
584
+ );
585
+ })}
586
+ <div ref={liveEndRef} />
587
+ </div>
588
+ </div>
589
+ )}
590
+ </div>
381
591
  );
382
592
  }