From 02308012a682b163ff8ddff3c1e1d7cec722c0bf Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sun, 5 Jul 2026 23:16:16 +0300 Subject: [PATCH 1/3] feat(gitmost-bridge): insert transcript into the recording page (#377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../editor/gitmost/gitmost-global-bridge.tsx | 13 ++++ .../editor/gitmost/gitmost-recording.test.ts | 65 +++++++++++++++++++ .../editor/gitmost/gitmost-recording.ts | 44 +++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 apps/client/src/features/editor/gitmost/gitmost-recording.test.ts diff --git a/apps/client/src/features/editor/gitmost/gitmost-global-bridge.tsx b/apps/client/src/features/editor/gitmost/gitmost-global-bridge.tsx index a1bef61f..1ac79041 100644 --- a/apps/client/src/features/editor/gitmost/gitmost-global-bridge.tsx +++ b/apps/client/src/features/editor/gitmost/gitmost-global-bridge.tsx @@ -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); diff --git a/apps/client/src/features/editor/gitmost/gitmost-recording.test.ts b/apps/client/src/features/editor/gitmost/gitmost-recording.test.ts new file mode 100644 index 00000000..29149822 --- /dev/null +++ b/apps/client/src/features/editor/gitmost/gitmost-recording.test.ts @@ -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(); + } + }); +}); diff --git a/apps/client/src/features/editor/gitmost/gitmost-recording.ts b/apps/client/src/features/editor/gitmost/gitmost-recording.ts index c9acec5f..2b5017cb 100644 --- a/apps/client/src/features/editor/gitmost/gitmost-recording.ts +++ b/apps/client/src/features/editor/gitmost/gitmost-recording.ts @@ -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. -- 2.52.0 From 751d55e9dbc0ed3e06f0ff908e9dba3c6a7c6840 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sun, 5 Jul 2026 23:41:44 +0300 Subject: [PATCH 2/3] fix(gitmost-bridge): neutralize col-0 block triggers in transcript lines + lock text-not-HTML (#378 review round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 review found two issues: - [regressions] Inserting a transcript line VERBATIM as a paragraph is unsafe on the git-sync doc->markdown->doc round-trip: the paragraph serializer emits text with no block-escape, so a line starting at col 0 with `- `/`> `/`# `/`1. `/```` ``` ````/`|`/ `> [!info]` silently re-parses into a list/quote/heading/code/table/callout (the last hits the #359 callout machinery). Safe under the host contract (every line prefixed `You:`/`Speaker N:`, which starts with a letter) but the helper didn't enforce it, and leading-whitespace / stray lines exist. Fix: trim each kept line (drops the indent leak), and if it STILL begins with a col-0 markdown block trigger, prepend an invisible zero-width space (U+200B) so the trigger isn't at col 0 — the round-trip keeps it a paragraph. (Backslash-escape was rejected: `marked` consumes the `\` on re-import, so it would render visibly then vanish. ZWSP is invisible and never markdown-escaped.) The serializer's missing block-escape is the pre-existing root cause; this is the boundary defense. Prefixed transcript lines never match the trigger regex → left byte-exact. - [test-coverage] The "inserted as TEXT, not HTML/markdown" contract wasn't locked (all test strings were plain alphabet). Added a test that inserts `bold and *stars* and [link](x)` (with Bold/Italic/Link marks registered so an insertContent(html) regression would parse them) and asserts one verbatim text node, no marks, and getHTML() has no live tags. Verified: apps/client tsc --noEmit 0 errors; gitmost bridge tests 4 passed; a NEW prosemirror-markdown round-trip test (real convertProseMirrorToMarkdown -> markdownToProseMirror) proves bare triggers corrupt while the ZWSP-neutralized form stays a single byte-preserved paragraph and normal You:/Speaker N: lines round-trip byte-exact — 3 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/gitmost/gitmost-recording.test.ts | 87 ++++++++++++++- .../editor/gitmost/gitmost-recording.ts | 52 +++++++-- .../gitmost-transcript-neutralization.test.ts | 104 ++++++++++++++++++ 3 files changed, 230 insertions(+), 13 deletions(-) create mode 100644 packages/prosemirror-markdown/test/gitmost-transcript-neutralization.test.ts diff --git a/apps/client/src/features/editor/gitmost/gitmost-recording.test.ts b/apps/client/src/features/editor/gitmost/gitmost-recording.test.ts index 29149822..bcd31451 100644 --- a/apps/client/src/features/editor/gitmost/gitmost-recording.test.ts +++ b/apps/client/src/features/editor/gitmost/gitmost-recording.test.ts @@ -4,19 +4,31 @@ 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 { Bold } from "@tiptap/extension-bold"; +import { Italic } from "@tiptap/extension-italic"; +import { Link } from "@tiptap/extension-link"; import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts"; +const ZWSP = "​"; // U+200B, the helper's block-trigger neutralizer + /** * #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. + * (Document/Paragraph/Text/Heading + Bold/Italic/Link marks so an HTML-parsing + * regression would be caught), asserting the resulting document rather than + * mocking the editor: transcript present -> "Transcript" heading + one paragraph + * per non-empty line; content is inserted as LITERAL TEXT (no HTML/markdown + * parsing); col-0 markdown block triggers are neutralized so git-sync keeps them + * paragraphs; absent/empty/non-string -> no-op. */ describe("gitmostInsertTranscriptIntoEditor", () => { const makeEditor = () => new Editor({ - extensions: [Document, Paragraph, Text, Heading], + // Bold/Italic/Link are registered specifically so that IF the helper ever + // regressed to inserting an HTML/markdown string (instead of a text node), + // TipTap would parse ``/`*..*`/`[..](..)` into marks and the literal- + // text assertions below would fail. + extensions: [Document, Paragraph, Text, Heading, Bold, Italic, Link], // 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" }] }, @@ -49,6 +61,73 @@ describe("gitmostInsertTranscriptIntoEditor", () => { editor.destroy(); }); + it("inserts HTML + markdown metacharacters as LITERAL text (no injection / no mark parsing)", () => { + const editor = makeEditor(); + const line = + "You: bold and *stars* and [link](x)"; + + const inserted = gitmostInsertTranscriptIntoEditor(editor, line); + expect(inserted).toBe(true); + + const paras = (editor.getJSON().content ?? []).filter( + (n: any) => n.type === "paragraph", + ) as any[]; + // The transcript line is exactly ONE paragraph holding a SINGLE text node + // whose text is the verbatim string — not split into bold/link/other nodes, + // not carrying any marks, not raw HTML. This FAILS if the helper switched to + // insertContent(htmlString): TipTap would then parse /[link](x)/*stars*. + const content = paras[paras.length - 1].content; + expect(content).toHaveLength(1); + expect(content[0].type).toBe("text"); + expect(content[0].marks ?? []).toEqual([]); + expect(content[0].text).toBe(line); + // And no bold/italic/link mark exists anywhere in the document. + const html = editor.getHTML(); + expect(html).not.toMatch(/<(strong|b|em|i|a)\b/); + // The angle brackets survived as escaped entities (literal text), not a live + //