diff --git a/src/frontend/client/src/pages/knowledge/FilePreview/MediaPlayer.tsx b/src/frontend/client/src/pages/knowledge/FilePreview/MediaPlayer.tsx
index 4a00a7188..4f9985da3 100644
--- a/src/frontend/client/src/pages/knowledge/FilePreview/MediaPlayer.tsx
+++ b/src/frontend/client/src/pages/knowledge/FilePreview/MediaPlayer.tsx
@@ -402,7 +402,7 @@ export function MediaPlayer({ kind, src, allowDownload = false, onDownload }: Me
className={cn(
"relative overflow-hidden",
onDarkStage ? "bg-black" : "bg-fill-2",
- fullscreen ? "flex h-full w-full flex-col justify-center" : "rounded-[6px]",
+ fullscreen ? "flex h-full w-full flex-col justify-center" : "rounded-[12px]",
)}
>
{isVideo ? (
@@ -437,7 +437,8 @@ export function MediaPlayer({ kind, src, allowDownload = false, onDownload }: Me
)}
= 0 ? rest.slice(0, nextHeading) : rest).trim();
}
-function MarkdownBody({ content }: { content: string }) {
+interface TranscriptCue {
+ /** Raw timestamp label, e.g. "00:00:03 - 00:00:05". */
+ time: string;
+ text: string;
+}
+
+/** A cue line opens with its timestamp in brackets: "[00:00:03 - 00:00:05] …". */
+const CUE_PATTERN = /^\[(\d{1,2}:\d{2}(?::\d{2})?(?:\s*-\s*\d{1,2}:\d{2}(?::\d{2})?)?)\]\s*/;
+
+/**
+ * Split a transcript into timestamp/text pairs so the time can be rendered as its
+ * own label instead of running inline with the sentence. Lines without a leading
+ * timestamp continue the cue above them; text before the first cue is returned as
+ * `preamble`. No cue found → the caller falls back to plain markdown.
+ */
+function parseTranscriptCues(markdown: string): { preamble: string; cues: TranscriptCue[] } {
+ const cues: TranscriptCue[] = [];
+ const preambleLines: string[] = [];
+
+ for (const rawLine of markdown.split("\n")) {
+ const line = rawLine.trim();
+ if (!line) continue;
+ const match = line.match(CUE_PATTERN);
+ if (match) {
+ cues.push({ time: match[1].replace(/\s*-\s*/, " - "), text: line.slice(match[0].length).trim() });
+ } else if (cues.length) {
+ const last = cues[cues.length - 1];
+ last.text = last.text ? `${last.text} ${line}` : line;
+ } else {
+ preambleLines.push(line);
+ }
+ }
+
+ return { preamble: preambleLines.join("\n\n"), cues };
+}
+
+/* Transcript markdown matches the cue text size. `.prose` (vendored typography CSS)
+ hard-sets its own font-size from --markdown-font-size — the global chat font-size
+ preference — and is emitted after the utilities, so the override needs `!`. */
+const TRANSCRIPT_MARKDOWN_SIZE = "!text-body";
+
+function TranscriptCueList({ cues }: { cues: TranscriptCue[] }) {
return (
-
+
+ {cues.map((cue, index) => (
+ -
+
{cue.time}
+ {cue.text}
+
+ ))}
+
+ );
+}
+
+function MarkdownBody({ content, className }: { content: string; className?: string }) {
+ return (
+
-
+ {/* 12px inset: the segmented control's own 3px padding lines its label
+ up with the 16px-inset transcript text below. */}
+
{/* Segmented control — mirrors the include/exclude tabs in
Subscription/CreateChannel/FilterConditionEditor. */}
@@ -224,8 +282,16 @@ function MediaTranscriptTabs({ fileUrl }: { fileUrl: string }) {
) : error ? (
{error}
) : (
-
-
+ // pb clears the AI dock pinned at the bottom of the page.
+
+ {cues.length ? (
+ <>
+ {preamble ? : null}
+
+ >
+ ) : (
+
+ )}
)}
@@ -263,7 +329,7 @@ export function RichKnowledgePreview({
{/* Side-by-side on md+: player left, transcript right, split by a single
divider line (no card border/shadow). Stacked on narrow screens. */}