feat(gitmost-bridge): insert transcript into the recording page (#377)

The native gitmost.app (stage 2) now sends a `transcript` field to
window.gitmost.createPageWithRecording, but the web bridge ignored it — the page
was created with audio only. Insert it.

- gitmost-recording.ts: add `transcript?: string` to GitmostCreatePagePayload
  (plain text, \n-separated `You:` / `Speaker N:` lines, ready to insert). Add a
  gitmostInsertTranscriptIntoEditor(editor, transcript) helper: a "Transcript"
  heading + one paragraph per non-empty line, each line inserted VERBATIM as a
  text node (never HTML → no injection), appended at doc end (below the audio).
  No-op when transcript is undefined/empty/whitespace-only/non-string.
- gitmost-global-bridge.tsx (createPageWithRecording): after the audio insert
  succeeds, call the helper inside a try/catch — best-effort, so a transcript
  failure can never turn the already-successful recording into an error (logs +
  still returns ok). Absent transcript → audio-only page, exactly as today.

DoD: recording with speech → page has audio AND a labeled transcript block;
without transcript → unchanged. Closes the web-side dependency of gitmost-app
stage 2. Verified: apps/client tsc --noEmit 0 errors; 2 unit tests
(transcript present → heading+paragraphs; undefined/""/whitespace/number/object
/null → no-op, doc unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 23:16:16 +03:00
parent 0665fcb630
commit 02308012a6
3 changed files with 122 additions and 0 deletions
@@ -24,6 +24,7 @@ import {
GitmostListPagesResult,
GitmostListSpacesResult,
gitmostDecodePayloadToFile,
gitmostInsertTranscriptIntoEditor,
gitmostUploadFileToEditor,
} from "@/features/editor/gitmost/gitmost-recording.ts";
@@ -281,6 +282,18 @@ export default function GitmostGlobalBridge() {
pageId: page.id,
};
}
// Best-effort: append the transcript (heading + one paragraph per line)
// below the just-inserted audio node. The audio insert already
// succeeded, so a transcript failure must NOT turn this into an error —
// wrap it and, on any throw, log and still return ok. A missing/empty/
// non-string transcript is a no-op inside the helper (audio only).
try {
gitmostInsertTranscriptIntoEditor(editor, payload?.transcript);
} catch (err) {
console.error("[gitmost] transcript insert failed", err);
}
return { ok: true, pageId: page.id };
} catch (err: any) {
console.error("[gitmost] createPageWithRecording failed", err);
@@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";
import { Editor } from "@tiptap/core";
import { Document } from "@tiptap/extension-document";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { Heading } from "@tiptap/extension-heading";
import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts";
/**
* #377 — the web-side bridge must append the native host's transcript below the
* recording. These exercise the pure insert helper through a REAL Tiptap editor
* (Document/Paragraph/Text/Heading), asserting the resulting document rather
* than mocking the editor: transcript present -> "Transcript" heading + one
* paragraph per non-empty line (verbatim); absent/empty/non-string -> no-op.
*/
describe("gitmostInsertTranscriptIntoEditor", () => {
const makeEditor = () =>
new Editor({
extensions: [Document, Paragraph, Text, Heading],
// Start from a single empty paragraph (a fresh page's baseline). The
// helper appends at the end of the doc, i.e. below existing content.
content: { type: "doc", content: [{ type: "paragraph" }] },
});
it("inserts a Transcript heading + one paragraph per non-empty line, verbatim", () => {
const editor = makeEditor();
const inserted = gitmostInsertTranscriptIntoEditor(
editor,
"You: hello there\nSpeaker 1: hi\n\nYou: bye",
);
expect(inserted).toBe(true);
const nodes = (editor.getJSON().content ?? []) as any[];
// A level-2 "Transcript" heading is present.
const heading = nodes.find((n) => n.type === "heading");
expect(heading?.attrs?.level).toBe(2);
expect(heading?.content?.[0]?.text).toBe("Transcript");
// Every non-empty transcript line becomes a paragraph, in order, verbatim;
// the blank line between them is dropped.
const texts = nodes
.filter((n) => n.type === "paragraph")
.map((n) => n.content?.[0]?.text)
.filter((t) => typeof t === "string");
expect(texts).toEqual(["You: hello there", "Speaker 1: hi", "You: bye"]);
editor.destroy();
});
it("is a no-op for undefined / empty / whitespace-only / non-string transcripts", () => {
for (const value of [undefined, "", " \n \n", 42, {}, null]) {
const editor = makeEditor();
const before = JSON.stringify(editor.getJSON());
const inserted = gitmostInsertTranscriptIntoEditor(editor, value as any);
expect(inserted).toBe(false);
// Document is untouched (audio-only behavior preserved).
expect(JSON.stringify(editor.getJSON())).toBe(before);
editor.destroy();
}
});
});
@@ -65,6 +65,11 @@ export interface GitmostCreatePagePayload {
base64: string;
filename: string;
mimeType: string;
// Optional transcript for the recording: plain text, `\n`-separated, each
// line already formatted as `You: ...` / `Speaker N: ...` by the native host
// (ready to insert, no parsing needed). Omitted (no speech / no models) ->
// audio only.
transcript?: string;
}
export interface GitmostCreatePageResult {
@@ -235,6 +240,45 @@ export async function gitmostUploadFileToEditor(
}
}
// Append a transcript block BELOW the recording's audio node in a live editor:
// a "Transcript" heading followed by one paragraph per non-empty transcript
// line. The transcript is plain text, `\n`-separated, each line already
// formatted as `You: ...` / `Speaker N: ...` by the native host — lines are
// inserted VERBATIM as text nodes (never HTML), so there is no injection
// surface. This is best-effort and meant to run AFTER the audio has already
// been inserted; the caller must guard against a throw so a transcript failure
// never fails the (already successful) recording. Returns true when a block was
// inserted, false when there was nothing to insert (transcript undefined/empty/
// not-a-string). A non-string value is a no-op, not an error.
export function gitmostInsertTranscriptIntoEditor(
editor: Editor,
transcript: unknown,
): boolean {
if (typeof transcript !== "string") return false;
// Keep each line's text verbatim; only drop blank (whitespace-only) lines.
const lines = transcript.split("\n").filter((line) => line.trim().length > 0);
if (lines.length === 0) return false;
const content = [
{
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "Transcript" }],
},
...lines.map((line) => ({
type: "paragraph",
content: [{ type: "text", text: line }],
})),
];
// Append at the end of the document. On a freshly-created recording page the
// audio node is the last block, so the end position places the transcript
// directly below it.
const endPos = editor.state.doc.content.size;
editor.chain().focus().insertContentAt(endPos, content).run();
return true;
}
// Full insert path used by the open-page bridge (insertRecording): guard the
// editor, validate/decode the payload, then upload. Never throws — resolves to
// a result code.