Skip to content

Commit 8112df4

Browse files
committed
feat(composer): inline code quotes and fenced code blocks behind a flag
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
1 parent 437c331 commit 8112df4

14 files changed

Lines changed: 706 additions & 44 deletions

packages/shared/src/flags.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@ export const GLM_MODEL_FLAG = "posthog-code-glm-model";
2121
export const SPOKEN_NARRATION_FLAG = "posthog-code-spoken-narration";
2222
// Gates importing and relaying local MCP servers into cloud task runs.
2323
export const LOCAL_MCP_IMPORT_FLAG = "posthog-code-local-mcp-import";
24+
/** Gates inline-code and fenced-code-block rendering in the message composer. */
25+
export const PROMPT_CODE_BLOCKS_FLAG = "posthog-code-prompt-code-blocks";

packages/ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"@tanstack/react-router-devtools": "catalog:",
7171
"@tanstack/react-virtual": "^3.13.26",
7272
"@tiptap/core": "^3.13.0",
73+
"@tiptap/extension-code-block": "^3.13.0",
7374
"@tiptap/extension-mention": "^3.13.0",
7475
"@tiptap/extension-placeholder": "^3.13.0",
7576
"@tiptap/pm": "^3.13.0",

packages/ui/src/features/message-editor/components/PromptInput.test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ vi.mock("../../skills/useSkills", () => ({
3838
useSkills: () => ({ data: [] }),
3939
}));
4040

41+
vi.mock("@posthog/ui/features/feature-flags/useFeatureFlag", () => ({
42+
useFeatureFlag: () => false,
43+
}));
44+
4145
vi.mock("../draftStore", () => ({
4246
useDraftStore: Object.assign(
4347
(selector: (s: unknown) => unknown) =>

packages/ui/src/features/message-editor/components/PromptInput.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import "./message-editor.css";
22
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
33
import { ArrowUp, Stop } from "@phosphor-icons/react";
44
import { InputGroup, InputGroupAddon, InputGroupButton } from "@posthog/quill";
5+
import { PROMPT_CODE_BLOCKS_FLAG } from "@posthog/shared";
56
import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts";
7+
import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag";
68
import type { PromptRecallHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall";
79
import { cycleModeOption } from "@posthog/ui/features/sessions/sessionStore";
810
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
@@ -155,6 +157,8 @@ export const PromptInput = forwardRef<EditorHandle, PromptInputProps>(
155157
const clearFocusRequest = useDraftStore((s) => s.actions.clearFocusRequest);
156158
const slotMachineMode = useSettingsStore((s) => s.slotMachineMode);
157159
const { data: skills } = useSkills();
160+
const codeBlocksEnabled =
161+
useFeatureFlag(PROMPT_CODE_BLOCKS_FLAG) || import.meta.env.DEV;
158162

159163
const {
160164
editor,
@@ -189,6 +193,7 @@ export const PromptInput = forwardRef<EditorHandle, PromptInputProps>(
189193
bashMode: enableBashMode,
190194
commands: enableCommands,
191195
},
196+
codeBlocks: codeBlocksEnabled,
192197
getPromptHistory,
193198
onPromptRecall,
194199
onBeforeSubmit,

packages/ui/src/features/message-editor/components/message-editor.css

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,36 @@
1212
display: inline;
1313
}
1414

15+
/* Inline code quote (`text`) and fenced code block (``` + Enter) */
16+
.cli-editor code.composer-inline-code {
17+
font-family: var(--font-mono);
18+
font-size: 12px;
19+
background: var(--gray-a3);
20+
border: 1px solid var(--gray-a5);
21+
border-radius: 3px;
22+
padding: 0 3px;
23+
}
24+
25+
.cli-editor pre.composer-code-block {
26+
font-family: var(--font-mono);
27+
font-size: 12px;
28+
background: var(--gray-a2);
29+
border: 1px solid var(--gray-a4);
30+
border-radius: 6px;
31+
padding: 6px 8px;
32+
margin: 4px 0;
33+
white-space: pre;
34+
overflow-x: auto;
35+
}
36+
37+
.cli-editor pre.composer-code-block code {
38+
font-family: inherit;
39+
font-size: inherit;
40+
background: transparent;
41+
border: none;
42+
padding: 0;
43+
}
44+
1545
/* Editor scrollbar - slim, transparent track, hugs right edge */
1646
.cli-editor-scroll::-webkit-scrollbar {
1747
width: 6px;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Extension } from "@tiptap/core";
2+
import { TextSelection } from "@tiptap/pm/state";
3+
4+
/**
5+
* Escape hatch for a fenced code block sitting at the very top of the doc:
6+
* ArrowUp on the block's first line inserts an empty paragraph above and
7+
* moves the caret there. Mirrors CodeBlock's built-in `exitOnArrowDown`,
8+
* which Tiptap ships no upward counterpart for.
9+
*/
10+
export const CodeBlockArrowExit = Extension.create({
11+
name: "codeBlockArrowExit",
12+
13+
addKeyboardShortcuts() {
14+
return {
15+
ArrowUp: ({ editor }) => {
16+
const { selection, schema } = editor.state;
17+
if (!selection.empty) return false;
18+
const { $from } = selection;
19+
if ($from.parent.type.name !== "codeBlock") return false;
20+
// Only when the block is the doc's first child and the caret is on
21+
// the first visual line, so plain in-block navigation still works.
22+
if ($from.index(0) !== 0) return false;
23+
if (!editor.view.endOfTextblock("up")) return false;
24+
25+
const paragraph = schema.nodes.paragraph.createAndFill();
26+
if (!paragraph) return false;
27+
28+
return editor.commands.command(({ tr, dispatch }) => {
29+
if (dispatch) {
30+
tr.insert(0, paragraph);
31+
tr.setSelection(TextSelection.create(tr.doc, 1));
32+
tr.scrollIntoView();
33+
}
34+
return true;
35+
});
36+
},
37+
};
38+
},
39+
});
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { Editor } from "@tiptap/core";
2+
import { afterEach, describe, expect, it } from "vitest";
3+
import { convertFenceLine, findCodeFenceLine } from "./codeFence";
4+
import { getEditorExtensions } from "./extensions";
5+
6+
let editor: Editor;
7+
8+
function buildEditor(content: string) {
9+
editor = new Editor({
10+
extensions: getEditorExtensions({
11+
sessionId: "test-session",
12+
fileMentions: false,
13+
issueMentions: false,
14+
commands: false,
15+
codeBlocks: true,
16+
}),
17+
content,
18+
});
19+
editor.commands.focus("end");
20+
return editor;
21+
}
22+
23+
afterEach(() => {
24+
editor?.destroy();
25+
});
26+
27+
describe("findCodeFenceLine", () => {
28+
it("matches a fence opening the paragraph", () => {
29+
buildEditor("<p>```js</p>");
30+
const fence = findCodeFenceLine(editor.view);
31+
expect(fence).toMatchObject({ language: "js", afterHardBreak: false });
32+
});
33+
34+
it("matches a fence on the line after a hard break", () => {
35+
buildEditor("<p>some text<br>```</p>");
36+
const fence = findCodeFenceLine(editor.view);
37+
expect(fence).toMatchObject({ language: "", afterHardBreak: true });
38+
});
39+
40+
it.each([
41+
{ name: "text before the fence on the same line", html: "<p>text ```</p>" },
42+
{ name: "no fence at all", html: "<p>hello</p>" },
43+
{ name: "a quadruple backtick run", html: "<p>````</p>" },
44+
])("returns null for $name", ({ html }) => {
45+
buildEditor(html);
46+
expect(findCodeFenceLine(editor.view)).toBeNull();
47+
});
48+
});
49+
50+
describe("convertFenceLine", () => {
51+
it("converts a fence after a hard break into a code block after the paragraph", () => {
52+
buildEditor("<p>some text<br>```py</p>");
53+
54+
expect(convertFenceLine(editor.view)).toBe(true);
55+
56+
const json = editor.getJSON();
57+
expect(json.content).toEqual([
58+
{ type: "paragraph", content: [{ type: "text", text: "some text" }] },
59+
{ type: "codeBlock", attrs: { language: "py" } },
60+
// StarterKit's TrailingNode keeps a paragraph after the block.
61+
{ type: "paragraph" },
62+
]);
63+
// Caret ends up inside the new code block.
64+
expect(editor.state.selection.$from.parent.type.name).toBe("codeBlock");
65+
});
66+
67+
it("converts a paragraph-opening fence in place", () => {
68+
buildEditor("<p>```js</p>");
69+
70+
expect(convertFenceLine(editor.view)).toBe(true);
71+
72+
expect(editor.getJSON().content).toEqual([
73+
{ type: "codeBlock", attrs: { language: "js" } },
74+
{ type: "paragraph" },
75+
]);
76+
expect(editor.state.selection.$from.parent.type.name).toBe("codeBlock");
77+
});
78+
79+
it("does nothing when the line is not a fence", () => {
80+
buildEditor("<p>text ```</p>");
81+
expect(convertFenceLine(editor.view)).toBe(false);
82+
});
83+
});
84+
85+
describe("space after ```", () => {
86+
it("does not create a code block (Enter is the only trigger)", () => {
87+
buildEditor("<p>```</p>");
88+
const { view } = editor;
89+
const handled = view.someProp("handleTextInput", (f) =>
90+
f(view, view.state.selection.from, view.state.selection.to, " ", () =>
91+
view.state.tr.insertText(" "),
92+
),
93+
);
94+
expect(handled ?? false).toBe(false);
95+
expect(editor.getJSON().content?.[0]?.type).toBe("paragraph");
96+
});
97+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { TextSelection } from "@tiptap/pm/state";
2+
import type { EditorView } from "@tiptap/pm/view";
3+
4+
export function inCodeBlock(view: EditorView): boolean {
5+
return view.state.selection.$from.parent.type.spec.code === true;
6+
}
7+
8+
interface CodeFenceLine {
9+
language: string;
10+
/** Doc position where the fence text starts, including the preceding hard break. */
11+
deleteFrom: number;
12+
/** The fence follows a Shift+Enter hard break rather than opening the paragraph. */
13+
afterHardBreak: boolean;
14+
}
15+
16+
// Caret at the end of a paragraph whose current line (after the last hard
17+
// break, or the whole paragraph) is a ``` fence opener.
18+
export function findCodeFenceLine(view: EditorView): CodeFenceLine | null {
19+
const { $from, empty } = view.state.selection;
20+
if (!empty) return null;
21+
const parent = $from.parent;
22+
if (parent.type.name !== "paragraph") return null;
23+
if ($from.parentOffset !== parent.content.size) return null;
24+
25+
let lineStartOffset = 0;
26+
let afterHardBreak = false;
27+
parent.forEach((child, offset) => {
28+
if (child.type.name === "hardBreak") {
29+
lineStartOffset = offset + child.nodeSize;
30+
afterHardBreak = true;
31+
}
32+
});
33+
// Atoms (mention chips) on the line show up as the replacement char and
34+
// fail the match, so a chip-bearing line is never treated as a fence.
35+
const lineText = parent.textBetween(
36+
lineStartOffset,
37+
parent.content.size,
38+
undefined,
39+
"",
40+
);
41+
const match = /^```(\w*)$/.exec(lineText);
42+
if (!match) return null;
43+
const lineStartPos = $from.start() + lineStartOffset;
44+
return {
45+
language: match[1],
46+
deleteFrom: afterHardBreak ? lineStartPos - 1 : lineStartPos,
47+
afterHardBreak,
48+
};
49+
}
50+
51+
// Shift+Enter on a ``` fence line converts it to a code block. The stock
52+
// input rules are disabled (they fire on "``` " with a space or Enter, and
53+
// only match a fence that opens the paragraph), so both cases are handled
54+
// here: a paragraph-opening fence converts in place, and a fence typed after
55+
// a hard break strips the break + fence and opens a code block right after
56+
// the paragraph.
57+
export function convertFenceLine(view: EditorView): boolean {
58+
const fence = findCodeFenceLine(view);
59+
if (!fence) return false;
60+
const codeBlockType = view.state.schema.nodes.codeBlock;
61+
if (!codeBlockType) return false;
62+
const attrs = { language: fence.language || null };
63+
64+
const { $from } = view.state.selection;
65+
const tr = view.state.tr.delete(fence.deleteFrom, $from.pos);
66+
67+
if (fence.afterHardBreak) {
68+
const codeBlock = codeBlockType.createAndFill(attrs);
69+
if (!codeBlock) return false;
70+
const afterParagraph = tr.mapping.map($from.after());
71+
tr.insert(afterParagraph, codeBlock);
72+
tr.setSelection(TextSelection.create(tr.doc, afterParagraph + 1));
73+
} else {
74+
tr.setBlockType(fence.deleteFrom, fence.deleteFrom, codeBlockType, attrs);
75+
}
76+
77+
tr.scrollIntoView();
78+
view.dispatch(tr);
79+
return true;
80+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Editor } from "@tiptap/core";
2+
import { describe, expect, it } from "vitest";
3+
import { getEditorExtensions } from "./extensions";
4+
5+
function buildEditor(codeBlocks: boolean) {
6+
return new Editor({
7+
extensions: getEditorExtensions({
8+
sessionId: "test-session",
9+
fileMentions: false,
10+
issueMentions: false,
11+
commands: false,
12+
codeBlocks,
13+
}),
14+
});
15+
}
16+
17+
describe("getEditorExtensions", () => {
18+
it("includes the code mark and codeBlock node when codeBlocks is on", () => {
19+
const editor = buildEditor(true);
20+
expect(editor.schema.marks.code).toBeDefined();
21+
expect(editor.schema.nodes.codeBlock).toBeDefined();
22+
editor.destroy();
23+
});
24+
25+
it("excludes the code mark and codeBlock node when codeBlocks is off", () => {
26+
const editor = buildEditor(false);
27+
expect(editor.schema.marks.code).toBeUndefined();
28+
expect(editor.schema.nodes.codeBlock).toBeUndefined();
29+
editor.destroy();
30+
});
31+
});

packages/ui/src/features/message-editor/tiptap/extensions.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
1+
import CodeBlock from "@tiptap/extension-code-block";
12
import Placeholder from "@tiptap/extension-placeholder";
23
import StarterKit from "@tiptap/starter-kit";
4+
import { CodeBlockArrowExit } from "./CodeBlockArrowExit";
35
import { createCommandMention } from "./CommandMention";
46
import { createFileMention } from "./FileMention";
57
import { createIssueMention } from "./IssueMention";
68
import { MentionChipNode } from "./MentionChipNode";
79

10+
// The stock CodeBlock input rules convert "``` " (trailing space) as well as
11+
// Enter; the composer only creates a fence on Shift+Enter (handled in
12+
// useTiptapEditor via convertFenceLine), so drop them entirely.
13+
const ComposerCodeBlock = CodeBlock.extend({
14+
addInputRules() {
15+
return [];
16+
},
17+
}).configure({ HTMLAttributes: { class: "composer-code-block" } });
18+
819
export interface EditorExtensionsOptions {
920
sessionId: string;
1021
placeholder?: string;
1122
fileMentions?: boolean;
1223
issueMentions?: boolean;
1324
commands?: boolean;
25+
codeBlocks?: boolean;
1426
}
1527

1628
export function getEditorExtensions(options: EditorExtensionsOptions) {
@@ -20,6 +32,7 @@ export function getEditorExtensions(options: EditorExtensionsOptions) {
2032
fileMentions = true,
2133
issueMentions = true,
2234
commands = true,
35+
codeBlocks = false,
2336
} = options;
2437

2538
const extensions = [
@@ -34,12 +47,18 @@ export function getEditorExtensions(options: EditorExtensionsOptions) {
3447
bold: false,
3548
italic: false,
3649
strike: false,
37-
code: false,
50+
code: codeBlocks
51+
? { HTMLAttributes: { class: "composer-inline-code" } }
52+
: false,
3853
}),
3954
Placeholder.configure({ placeholder }),
4055
MentionChipNode,
4156
];
4257

58+
if (codeBlocks) {
59+
extensions.push(ComposerCodeBlock, CodeBlockArrowExit);
60+
}
61+
4362
if (fileMentions) {
4463
extensions.push(createFileMention(sessionId));
4564
}

0 commit comments

Comments
 (0)