@agent-native/core 0.157.4 → 0.157.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.
- package/corpus/README.md +1 -1
- package/corpus/templates/clips/actions/create-meeting.ts +19 -1
- package/corpus/templates/clips/app/components/meetings/bullet-link.tsx +48 -25
- package/corpus/templates/clips/app/components/meetings/canvas-editor.tsx +7 -6
- package/corpus/templates/clips/app/components/meetings/transcript-bubbles.tsx +388 -178
- package/corpus/templates/clips/app/components/player/transcript-panel.tsx +7 -13
- package/corpus/templates/clips/app/components/transcript/transcript-segment-row.tsx +98 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +4 -0
- package/corpus/templates/clips/app/lib/public-meeting.ts +4 -0
- package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +49 -31
- package/corpus/templates/clips/app/routes/share.meeting.$meetingId.tsx +15 -1
- package/corpus/templates/clips/netlify.toml +4 -4
- package/corpus/templates/clips/server/routes/api/public-meeting.get.ts +10 -0
- package/corpus/templates/clips/shared/transcript-segments.ts +20 -2
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/deploy/build.js +28 -11
- package/dist/notifications/routes.d.ts +1 -1
- package/docs/content/actions-access-control.mdx +226 -0
- package/docs/content/actions-advanced.mdx +152 -0
- package/docs/content/actions-defining.mdx +275 -0
- package/docs/content/actions-other-surfaces.mdx +167 -0
- package/docs/content/actions-overview.mdx +214 -0
- package/docs/content/actions-run-context.mdx +180 -0
- package/docs/content/agent-surfaces.mdx +1 -1
- package/docs/content/agent-teams.mdx +1 -1
- package/docs/content/agent-web-surfaces.mdx +1 -1
- package/docs/content/audit-log.mdx +1 -1
- package/docs/content/automation-connectors.mdx +1 -1
- package/docs/content/automations.mdx +1 -1
- package/docs/content/blueprint-installer.mdx +1 -1
- package/docs/content/cli-adapters.mdx +1 -1
- package/docs/content/client-agent-chat.mdx +1 -1
- package/docs/content/client-data.mdx +2 -2
- package/docs/content/client-overview.mdx +2 -2
- package/docs/content/components.mdx +1 -1
- package/docs/content/data-programs.mdx +2 -2
- package/docs/content/database.mdx +1 -1
- package/docs/content/deployment.mdx +1 -1
- package/docs/content/docs-components.mdx +14 -3
- package/docs/content/drop-in-agent.mdx +2 -2
- package/docs/content/durable-resume.mdx +1 -1
- package/docs/content/evals.mdx +1 -1
- package/docs/content/extensions.mdx +1 -1
- package/docs/content/external-agents-catalog.mdx +2 -2
- package/docs/content/external-agents.mdx +1 -1
- package/docs/content/generative-ui.mdx +1 -1
- package/docs/content/getting-started.mdx +1 -1
- package/docs/content/http-api.mdx +4 -4
- package/docs/content/human-approval.mdx +1 -1
- package/docs/content/key-concepts.mdx +1 -1
- package/docs/content/mcp-apps.mdx +1 -1
- package/docs/content/mcp-clients.mdx +1 -1
- package/docs/content/mcp-protocol.mdx +1 -1
- package/docs/content/native-chat-ui.mdx +1 -1
- package/docs/content/observability.mdx +1 -1
- package/docs/content/organizations-teams-permissions.mdx +1 -1
- package/docs/content/processors.mdx +1 -1
- package/docs/content/pure-agent-apps.mdx +1 -1
- package/docs/content/recurring-jobs.mdx +1 -1
- package/docs/content/server-database.mdx +1 -1
- package/docs/content/server-overview.mdx +3 -3
- package/docs/content/server-routes.mdx +2 -2
- package/docs/content/template-analytics-developers.mdx +1 -1
- package/docs/content/template-assets-developers.mdx +1 -1
- package/docs/content/template-brain-developers.mdx +1 -1
- package/docs/content/template-calendar-developers.mdx +1 -1
- package/docs/content/template-chat-first-edits.mdx +1 -1
- package/docs/content/template-chat.mdx +1 -1
- package/docs/content/template-content-developers.mdx +1 -1
- package/docs/content/template-forms-developers.mdx +1 -1
- package/docs/content/template-forms.mdx +1 -1
- package/docs/content/template-mail-developers.mdx +2 -2
- package/docs/content/toolkit-org-team.mdx +1 -1
- package/docs/content/tracking.mdx +1 -1
- package/docs/content/what-is-agent-native.mdx +1 -1
- package/package.json +3 -3
- package/docs/content/actions.mdx +0 -750
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
210
|
+
if (last && last.speaker.key === speaker.key) {
|
|
83
211
|
last.segments.push({ seg, index });
|
|
84
212
|
} else {
|
|
85
|
-
groups.push({
|
|
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(
|
|
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
|
|
183
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
<
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
302
|
-
|
|
458
|
+
disabled={!matchIndexes.length}
|
|
459
|
+
aria-label={t("transcriptBubbles.searchPrevMatch")}
|
|
460
|
+
onClick={() => goToMatch(-1)}
|
|
303
461
|
>
|
|
304
|
-
<
|
|
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
|
-
|
|
380
|
-
|
|
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
|
}
|