From f93f25b6b4904655ddd084bbb9e83086f361f3de Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 15:23:17 -0700 Subject: [PATCH 01/10] double paste to undo chip auto-conversion --- .../core/src/message-editor/paste.test.ts | 51 ++++++++ packages/core/src/message-editor/paste.ts | 15 +++ .../message-editor/tiptap/useTiptapEditor.ts | 117 ++++++++++++++++-- 3 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/message-editor/paste.test.ts diff --git a/packages/core/src/message-editor/paste.test.ts b/packages/core/src/message-editor/paste.test.ts new file mode 100644 index 0000000000..8fc9b97ab8 --- /dev/null +++ b/packages/core/src/message-editor/paste.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { type AutoConvertedPaste, isRepeatOfAutoConvertedPaste } from "./paste"; + +const lastPaste: AutoConvertedPaste = { + clipboardText: "https://github.com/posthog/code/issues/42", + insertText: "https://github.com/posthog/code/issues/42", + chipId: "chip-1", +}; + +describe("isRepeatOfAutoConvertedPaste", () => { + it.each([ + { + name: "same clipboard text as the last conversion", + last: lastPaste, + clipboardText: lastPaste.clipboardText, + expected: true, + }, + { + name: "no prior conversion", + last: null, + clipboardText: lastPaste.clipboardText, + expected: false, + }, + { + name: "different clipboard text", + last: lastPaste, + clipboardText: "something else", + expected: false, + }, + { + name: "clipboard text differing only by whitespace", + last: lastPaste, + clipboardText: `${lastPaste.clipboardText} `, + expected: false, + }, + { + name: "empty clipboard text", + last: lastPaste, + clipboardText: "", + expected: false, + }, + { + name: "undefined clipboard text", + last: lastPaste, + clipboardText: undefined, + expected: false, + }, + ])("returns $expected for $name", ({ last, clipboardText, expected }) => { + expect(isRepeatOfAutoConvertedPaste(last, clipboardText)).toBe(expected); + }); +}); diff --git a/packages/core/src/message-editor/paste.ts b/packages/core/src/message-editor/paste.ts index a9307bcb66..4284a0255f 100644 --- a/packages/core/src/message-editor/paste.ts +++ b/packages/core/src/message-editor/paste.ts @@ -29,3 +29,18 @@ export function buildPastedTextLabel( ): string { return `Pasted text #${pasteNumber} (${lineCount} lines)`; } + +export interface AutoConvertedPaste { + clipboardText: string; + insertText: string; + chipId: string; +} + +export function isRepeatOfAutoConvertedPaste( + last: AutoConvertedPaste | null, + clipboardText: string | null | undefined, +): last is AutoConvertedPaste { + return ( + last !== null && !!clipboardText && clipboardText === last.clipboardText + ); +} diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index f370f694ff..e96a6ce6fa 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -11,10 +11,12 @@ import { parseGithubIssueUrl, } from "@posthog/core/message-editor/githubIssueUrl"; import { + type AutoConvertedPaste, buildMarkdownLink, buildPastedTextLabel, extractBashCommand, isBashModeText, + isRepeatOfAutoConvertedPaste, isUrlOnly, shouldAutoConvertLongText, } from "@posthog/core/message-editor/paste"; @@ -69,6 +71,11 @@ export interface UseTiptapEditorOptions { const EDITOR_CLASS = "cli-editor min-h-[1.5em] w-full break-words border-none bg-transparent pr-2 text-[14px] text-[var(--gray-12)] outline-none [overflow-wrap:break-word] [white-space:pre-wrap] [word-break:break-word]"; +interface TrackedAutoConvertedPaste extends AutoConvertedPaste { + chipInserted: boolean; + canceled: boolean; +} + function insertChipWithTrailingSpace( view: EditorView, attrs: { @@ -76,6 +83,7 @@ function insertChipWithTrailingSpace( id: string; label: string; pastedText?: boolean; + chipId?: string; }, ): void { const chipNode = view.state.schema.nodes.mentionChip.create({ @@ -92,8 +100,14 @@ async function pasteTextAsFile( view: EditorView, text: string, pasteCountRef: React.MutableRefObject, + tracked?: TrackedAutoConvertedPaste, ): Promise { const result = await persistTextContent(text); + if (tracked?.canceled) { + view.dispatch(view.state.tr.insertText(text)); + view.focus(); + return; + } pasteCountRef.current += 1; const lineCount = text.split("\n").length; insertChipWithTrailingSpace(view, { @@ -101,15 +115,51 @@ async function pasteTextAsFile( id: result.path, label: buildPastedTextLabel(pasteCountRef.current, lineCount), pastedText: true, + chipId: tracked?.chipId, }); + if (tracked) tracked.chipInserted = true; view.focus(); } function insertGithubRefPlaceholder( view: EditorView, parsed: ParsedGithubIssueUrl, + chipId: string, ): void { - insertChipWithTrailingSpace(view, buildGithubRefPlaceholderChip(parsed)); + insertChipWithTrailingSpace(view, { + ...buildGithubRefPlaceholderChip(parsed), + chipId, + }); +} + +function replaceChipWithText( + view: EditorView, + chipId: string, + text: string, +): boolean { + const { doc, selection } = view.state; + let chipFrom = -1; + let chipTo = -1; + doc.descendants((node, pos) => { + if (chipFrom >= 0) return false; + if (node.type.name !== "mentionChip") return; + if (node.attrs.chipId !== chipId) return; + const nodeEnd = pos + node.nodeSize; + // Also swallow the trailing space the chip insertion added. + const after = doc.textBetween( + nodeEnd, + Math.min(nodeEnd + 1, doc.content.size), + ); + chipFrom = pos; + chipTo = after === " " ? nodeEnd + 1 : nodeEnd; + return false; + }); + if (chipFrom < 0) return false; + // Only treat it as a double paste while the caret still follows the chip. + if (selection.from !== chipTo && selection.from !== chipTo - 1) return false; + view.dispatch(view.state.tr.insertText(text, chipFrom, chipTo)); + view.focus(); + return true; } async function fetchGithubRefTitle( @@ -234,6 +284,9 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const draftRef = useRef | null>(null); const pasteCountRef = useRef(0); + const lastAutoConvertedPasteRef = useRef( + null, + ); const historyActions = usePromptHistoryStore.getState(); const [isEmptyState, setIsEmptyState] = useState(true); const [isReady, setIsReady] = useState(false); @@ -376,6 +429,10 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const clipboardText = event.clipboardData?.getData("text/plain"); const trimmedClipboardText = clipboardText?.trim(); + // Only the immediately-following paste can undo an auto-conversion. + const lastConverted = lastAutoConvertedPasteRef.current; + lastAutoConvertedPasteRef.current = null; + // Auto-wrap selected text as markdown link when pasting a URL if ( from !== to && @@ -398,12 +455,42 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { return true; } + // Pasting the same clipboard again undoes the chip auto-conversion + if ( + from === to && + isRepeatOfAutoConvertedPaste(lastConverted, clipboardText) + ) { + if ( + replaceChipWithText( + view, + lastConverted.chipId, + lastConverted.insertText, + ) + ) { + event.preventDefault(); + return true; + } + if (!lastConverted.chipInserted) { + event.preventDefault(); + lastConverted.canceled = true; + return true; + } + } + // Auto-convert a pasted GitHub issue or PR URL into a chip - if (from === to && trimmedClipboardText) { + if (from === to && clipboardText && trimmedClipboardText) { const parsedRef = parseGithubIssueUrl(trimmedClipboardText); if (parsedRef) { event.preventDefault(); - insertGithubRefPlaceholder(view, parsedRef); + const chipId = crypto.randomUUID(); + insertGithubRefPlaceholder(view, parsedRef, chipId); + lastAutoConvertedPasteRef.current = { + clipboardText, + insertText: clipboardText, + chipId, + chipInserted: true, + canceled: false, + }; void resolveGithubRefChip(view, parsedRef); return true; } @@ -458,13 +545,29 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { ) { event.preventDefault(); + const tracked: TrackedAutoConvertedPaste = { + clipboardText: clipboardText || effectiveText, + insertText: effectiveText, + chipId: crypto.randomUUID(), + chipInserted: false, + canceled: false, + }; + lastAutoConvertedPasteRef.current = tracked; + (async () => { try { - await pasteTextAsFile(view, effectiveText, pasteCountRef); - showPasteHint( - "Pasted as file attachment", - "Click the chip to convert back to text.", + await pasteTextAsFile( + view, + effectiveText, + pasteCountRef, + tracked, ); + if (!tracked.canceled) { + showPasteHint( + "Pasted as file attachment", + "Paste again or click the chip to convert back to text.", + ); + } } catch (_error) { toast.error("Failed to convert pasted text to attachment"); } From ea19e93adf5141de1f4f7f83af06b811b3ce2317 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 15:43:21 -0700 Subject: [PATCH 02/10] make double paste the only un-convert path --- .../message-editor/tiptap/MentionChipView.tsx | 82 ++----------------- .../message-editor/tiptap/useTiptapEditor.ts | 19 +++-- 2 files changed, 20 insertions(+), 81 deletions(-) diff --git a/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx b/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx index 24f13336a2..4303dee112 100644 --- a/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx +++ b/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx @@ -11,12 +11,8 @@ import { XIcon, } from "@phosphor-icons/react"; import { Chip } from "@posthog/quill"; -import { useSettingsStore as useFeatureSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; -import type { Node as PmNode } from "@tiptap/pm/model"; -import type { Editor } from "@tiptap/react"; import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react"; -import { readAbsoluteFile } from "../hostApi"; import type { ChipType, MentionChipAttrs } from "./MentionChipNode"; const chipBase = "group/chip relative top-px active:translate-y-0 pl-1"; @@ -107,63 +103,13 @@ function DefaultChip({ return chipContent; } -function PastedTextChip({ - label, - filePath, - editor, - node, - getPos, - selected, - onRemove, -}: { - label: string; - filePath: string; - editor: Editor; - node: PmNode; - getPos: () => number | undefined; - selected: boolean; - onRemove: () => void; -}) { - const handleClick = async () => { - useFeatureSettingsStore.getState().markHintLearned("paste-as-file"); - - const content = await readAbsoluteFile({ - filePath, - }); - if (!content) return; - - const pos = getPos(); - if (pos == null) return; - - editor - .chain() - .focus() - .deleteRange({ from: pos, to: pos + node.nodeSize }) - .insertContentAt(pos, content) - .run(); - }; - - return ( - - - @{label} - - - ); -} - export function MentionChipView({ node, getPos, editor, selected, }: NodeViewProps) { - const { type, id, label, pastedText } = node.attrs as MentionChipAttrs; + const { type, id, label } = node.attrs as MentionChipAttrs; const handleRemove = () => { const pos = getPos(); @@ -177,25 +123,13 @@ export function MentionChipView({ return ( - {pastedText ? ( - - ) : ( - - )} + ); } diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index e96a6ce6fa..8dc748838a 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -103,11 +103,7 @@ async function pasteTextAsFile( tracked?: TrackedAutoConvertedPaste, ): Promise { const result = await persistTextContent(text); - if (tracked?.canceled) { - view.dispatch(view.state.tr.insertText(text)); - view.focus(); - return; - } + if (tracked?.canceled) return; pasteCountRef.current += 1; const lineCount = text.split("\n").length; insertChipWithTrailingSpace(view, { @@ -468,11 +464,18 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { ) ) { event.preventDefault(); + useFeatureSettingsStore + .getState() + .markHintLearned("paste-as-file"); return true; } if (!lastConverted.chipInserted) { event.preventDefault(); lastConverted.canceled = true; + useFeatureSettingsStore + .getState() + .markHintLearned("paste-as-file"); + view.dispatch(view.state.tr.insertText(lastConverted.insertText)); return true; } } @@ -565,11 +568,13 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { if (!tracked.canceled) { showPasteHint( "Pasted as file attachment", - "Paste again or click the chip to convert back to text.", + "Paste again to convert back to text.", ); } } catch (_error) { - toast.error("Failed to convert pasted text to attachment"); + if (!tracked.canceled) { + toast.error("Failed to convert pasted text to attachment"); + } } })(); From c06eed1d821ac16669db0e6db745aabb7e8e7829 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 16:04:54 -0700 Subject: [PATCH 03/10] add paste again tooltip on pasted text chips --- .../message-editor/tiptap/MentionChipView.tsx | 11 +++++++++-- .../features/message-editor/tiptap/useTiptapEditor.ts | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx b/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx index 4303dee112..5ebebd7e2a 100644 --- a/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx +++ b/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx @@ -64,12 +64,14 @@ function DefaultChip({ type, id, label, + pastedText, selected, onRemove, }: { type: string; id: string; label: string; + pastedText: boolean; selected: boolean; onRemove: () => void; }) { @@ -97,7 +99,11 @@ function DefaultChip({ ); if (isFile || isFolder) { - return {chipContent}; + return ( + + {chipContent} + + ); } return chipContent; @@ -109,7 +115,7 @@ export function MentionChipView({ editor, selected, }: NodeViewProps) { - const { type, id, label } = node.attrs as MentionChipAttrs; + const { type, id, label, pastedText } = node.attrs as MentionChipAttrs; const handleRemove = () => { const pos = getPos(); @@ -127,6 +133,7 @@ export function MentionChipView({ type={type} id={id} label={label} + pastedText={pastedText} selected={selected} onRemove={handleRemove} /> diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 8dc748838a..662b9d8f5c 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -568,7 +568,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { if (!tracked.canceled) { showPasteHint( "Pasted as file attachment", - "Paste again to convert back to text.", + "Paste again to expand as text.", ); } } catch (_error) { From 193f2dec7452bfaa1e86ab9780644c09a0246120 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 16:10:12 -0700 Subject: [PATCH 04/10] add got it action to paste hints --- .../ui/src/features/message-editor/tiptap/useTiptapEditor.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 662b9d8f5c..5916844710 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -218,7 +218,10 @@ function showPasteHint(message: string, description: string): void { message === "Pasted as file attachment" ? "paste-as-file" : "paste-inline"; if (!store.shouldShowHint(key)) return; store.recordHintShown(key); - toast.info(message, description); + toast.info(message, { + description, + action: { label: "Got it", onClick: () => store.markHintLearned(key) }, + }); } export function useTiptapEditor(options: UseTiptapEditorOptions) { From 2573364ae1149197a49fb9b34b5a8770abeaaf3a Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 17:10:24 -0700 Subject: [PATCH 05/10] gate paste-as-file hint by conversion kind --- .../features/message-editor/tiptap/useTiptapEditor.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 5916844710..b92ff005ef 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -72,6 +72,7 @@ const EDITOR_CLASS = "cli-editor min-h-[1.5em] w-full break-words border-none bg-transparent pr-2 text-[14px] text-[var(--gray-12)] outline-none [overflow-wrap:break-word] [white-space:pre-wrap] [word-break:break-word]"; interface TrackedAutoConvertedPaste extends AutoConvertedPaste { + kind: "file" | "github-ref"; chipInserted: boolean; canceled: boolean; } @@ -467,9 +468,11 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { ) ) { event.preventDefault(); - useFeatureSettingsStore - .getState() - .markHintLearned("paste-as-file"); + if (lastConverted.kind === "file") { + useFeatureSettingsStore + .getState() + .markHintLearned("paste-as-file"); + } return true; } if (!lastConverted.chipInserted) { @@ -494,6 +497,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { clipboardText, insertText: clipboardText, chipId, + kind: "github-ref", chipInserted: true, canceled: false, }; @@ -555,6 +559,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { clipboardText: clipboardText || effectiveText, insertText: effectiveText, chipId: crypto.randomUUID(), + kind: "file", chipInserted: false, canceled: false, }; From 1191bc82e5b368acfd55aed434b01ea51d22bd1e Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 17:10:53 -0700 Subject: [PATCH 06/10] track auto-converted paste with one status --- .../message-editor/tiptap/useTiptapEditor.ts | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index b92ff005ef..1adc258410 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -73,8 +73,7 @@ const EDITOR_CLASS = interface TrackedAutoConvertedPaste extends AutoConvertedPaste { kind: "file" | "github-ref"; - chipInserted: boolean; - canceled: boolean; + status: "pending" | "inserted" | "canceled"; } function insertChipWithTrailingSpace( @@ -104,7 +103,7 @@ async function pasteTextAsFile( tracked?: TrackedAutoConvertedPaste, ): Promise { const result = await persistTextContent(text); - if (tracked?.canceled) return; + if (tracked?.status === "canceled") return; pasteCountRef.current += 1; const lineCount = text.split("\n").length; insertChipWithTrailingSpace(view, { @@ -114,7 +113,7 @@ async function pasteTextAsFile( pastedText: true, chipId: tracked?.chipId, }); - if (tracked) tracked.chipInserted = true; + if (tracked) tracked.status = "inserted"; view.focus(); } @@ -475,9 +474,9 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { } return true; } - if (!lastConverted.chipInserted) { + if (lastConverted.status === "pending") { event.preventDefault(); - lastConverted.canceled = true; + lastConverted.status = "canceled"; useFeatureSettingsStore .getState() .markHintLearned("paste-as-file"); @@ -498,8 +497,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { insertText: clipboardText, chipId, kind: "github-ref", - chipInserted: true, - canceled: false, + status: "inserted", }; void resolveGithubRefChip(view, parsedRef); return true; @@ -560,8 +558,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { insertText: effectiveText, chipId: crypto.randomUUID(), kind: "file", - chipInserted: false, - canceled: false, + status: "pending", }; lastAutoConvertedPasteRef.current = tracked; @@ -573,14 +570,14 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { pasteCountRef, tracked, ); - if (!tracked.canceled) { + if (tracked.status !== "canceled") { showPasteHint( "Pasted as file attachment", "Paste again to expand as text.", ); } } catch (_error) { - if (!tracked.canceled) { + if (tracked.status !== "canceled") { toast.error("Failed to convert pasted text to attachment"); } } From 6c99c768c567c2f7796dbdeea0ecd9d37ca807b2 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 17:13:16 -0700 Subject: [PATCH 07/10] extract shared chip range lookup --- .../message-editor/tiptap/MentionChipNode.ts | 42 ++++------- .../message-editor/tiptap/chipRange.test.ts | 75 +++++++++++++++++++ .../message-editor/tiptap/chipRange.ts | 27 +++++++ .../message-editor/tiptap/useTiptapEditor.ts | 26 ++----- 4 files changed, 122 insertions(+), 48 deletions(-) create mode 100644 packages/ui/src/features/message-editor/tiptap/chipRange.test.ts create mode 100644 packages/ui/src/features/message-editor/tiptap/chipRange.ts diff --git a/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts b/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts index 9f72f163a3..ef4540fe39 100644 --- a/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts +++ b/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts @@ -1,6 +1,7 @@ import type { UploadableSkillSource } from "@posthog/shared"; import { mergeAttributes, Node } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; +import { findChipRangeById } from "./chipRange"; import { MentionChipView } from "./MentionChipView"; export type ChipType = @@ -103,39 +104,22 @@ export const MentionChipNode = Node.create({ replaceMentionChipById: (chipId: string, attrs: Partial) => ({ tr, state, dispatch }) => { - let found = false; - state.doc.descendants((node, pos) => { - if (found) return false; - if (node.type.name !== "mentionChip") return; - if (node.attrs.chipId !== chipId) return; - found = true; - tr.setNodeMarkup(pos, undefined, { ...node.attrs, ...attrs }); - return false; - }); - if (found && dispatch) dispatch(tr); - return found; + const range = findChipRangeById(state.doc, chipId); + if (!range) return false; + const node = state.doc.nodeAt(range.from); + if (!node) return false; + tr.setNodeMarkup(range.from, undefined, { ...node.attrs, ...attrs }); + if (dispatch) dispatch(tr); + return true; }, removeMentionChipById: (chipId: string) => ({ tr, state, dispatch }) => { - let found = false; - state.doc.descendants((node, pos) => { - if (found) return false; - if (node.type.name !== "mentionChip") return; - if (node.attrs.chipId !== chipId) return; - found = true; - const from = pos; - const to = pos + node.nodeSize; - // Also swallow a trailing single space the suggestion adds. - const after = state.doc.textBetween( - to, - Math.min(to + 1, state.doc.content.size), - ); - tr.delete(from, after === " " ? to + 1 : to); - return false; - }); - if (found && dispatch) dispatch(tr); - return found; + const range = findChipRangeById(state.doc, chipId); + if (!range) return false; + tr.delete(range.from, range.to); + if (dispatch) dispatch(tr); + return true; }, }; }, diff --git a/packages/ui/src/features/message-editor/tiptap/chipRange.test.ts b/packages/ui/src/features/message-editor/tiptap/chipRange.test.ts new file mode 100644 index 0000000000..6a385333b8 --- /dev/null +++ b/packages/ui/src/features/message-editor/tiptap/chipRange.test.ts @@ -0,0 +1,75 @@ +import { getSchema } from "@tiptap/core"; +import { Node as PmNode } from "@tiptap/pm/model"; +import StarterKit from "@tiptap/starter-kit"; +import { describe, expect, it } from "vitest"; +import { findChipRangeById } from "./chipRange"; +import { MentionChipNode } from "./MentionChipNode"; + +const schema = getSchema([StarterKit, MentionChipNode]); + +function chip(chipId: string | null) { + return { + type: "mentionChip", + attrs: { + type: "file", + id: "/tmp/pasted.txt", + label: "Pasted text #1 (2 lines)", + pastedText: true, + chipId, + }, + }; +} + +function text(value: string) { + return { type: "text", text: value }; +} + +function docOf(...content: object[]): PmNode { + return PmNode.fromJSON(schema, { + type: "doc", + content: [{ type: "paragraph", content }], + }); +} + +describe("findChipRangeById", () => { + it.each([ + { + name: "chip followed by a trailing space swallows the space", + doc: docOf(chip("a"), text(" tail")), + chipId: "a", + expected: { from: 1, to: 3 }, + }, + { + name: "chip at the end of the doc", + doc: docOf(text("hi "), chip("a")), + chipId: "a", + expected: { from: 4, to: 5 }, + }, + { + name: "chip followed by non-space text", + doc: docOf(chip("a"), text("x")), + chipId: "a", + expected: { from: 1, to: 2 }, + }, + { + name: "matching chip among several", + doc: docOf(chip("a"), text(" "), chip("b"), text(" ")), + chipId: "b", + expected: { from: 3, to: 5 }, + }, + { + name: "no chip with the requested id", + doc: docOf(chip("a"), text(" ")), + chipId: "missing", + expected: null, + }, + { + name: "chip without a chipId attribute", + doc: docOf(chip(null), text(" ")), + chipId: "a", + expected: null, + }, + ])("$name", ({ doc, chipId, expected }) => { + expect(findChipRangeById(doc, chipId)).toEqual(expected); + }); +}); diff --git a/packages/ui/src/features/message-editor/tiptap/chipRange.ts b/packages/ui/src/features/message-editor/tiptap/chipRange.ts new file mode 100644 index 0000000000..c9dbf12e17 --- /dev/null +++ b/packages/ui/src/features/message-editor/tiptap/chipRange.ts @@ -0,0 +1,27 @@ +import type { Node as PmNode } from "@tiptap/pm/model"; + +export interface ChipRange { + from: number; + to: number; +} + +export function findChipRangeById( + doc: PmNode, + chipId: string, +): ChipRange | null { + let range: ChipRange | null = null; + doc.descendants((node, pos) => { + if (range) return false; + if (node.type.name !== "mentionChip") return; + if (node.attrs.chipId !== chipId) return; + const nodeEnd = pos + node.nodeSize; + // Also swallow the trailing space the chip insertion added. + const after = doc.textBetween( + nodeEnd, + Math.min(nodeEnd + 1, doc.content.size), + ); + range = { from: pos, to: after === " " ? nodeEnd + 1 : nodeEnd }; + return false; + }); + return range; +} diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 1adc258410..9e99dfa120 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -30,6 +30,7 @@ import type React from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { getGithubIssue, getGithubPullRequest } from "../hostApi"; import { usePromptHistoryStore } from "../promptHistoryStore"; +import { findChipRangeById } from "../tiptap/chipRange"; import { getEditorExtensions } from "../tiptap/extensions"; import { type DraftContext, @@ -134,26 +135,13 @@ function replaceChipWithText( text: string, ): boolean { const { doc, selection } = view.state; - let chipFrom = -1; - let chipTo = -1; - doc.descendants((node, pos) => { - if (chipFrom >= 0) return false; - if (node.type.name !== "mentionChip") return; - if (node.attrs.chipId !== chipId) return; - const nodeEnd = pos + node.nodeSize; - // Also swallow the trailing space the chip insertion added. - const after = doc.textBetween( - nodeEnd, - Math.min(nodeEnd + 1, doc.content.size), - ); - chipFrom = pos; - chipTo = after === " " ? nodeEnd + 1 : nodeEnd; - return false; - }); - if (chipFrom < 0) return false; + const range = findChipRangeById(doc, chipId); + if (!range) return false; // Only treat it as a double paste while the caret still follows the chip. - if (selection.from !== chipTo && selection.from !== chipTo - 1) return false; - view.dispatch(view.state.tr.insertText(text, chipFrom, chipTo)); + if (selection.from !== range.to && selection.from !== range.to - 1) { + return false; + } + view.dispatch(view.state.tr.insertText(text, range.from, range.to)); view.focus(); return true; } From ac025d39adac24f92425323588d97c583871fa0c Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 17:14:03 -0700 Subject: [PATCH 08/10] resolve github ref chips by chip id --- .../message-editor/tiptap/useTiptapEditor.ts | 32 +++++++------------ 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 9e99dfa120..4e2b875488 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -169,35 +169,25 @@ async function fetchGithubRefTitle( async function resolveGithubRefChip( view: EditorView, parsed: ParsedGithubIssueUrl, + chipId: string, ): Promise { - const chipType = parsed.kind === "pr" ? "github_pr" : "github_issue"; - const placeholderLabel = `#${parsed.number} - Loading...`; const title = await fetchGithubRefTitle(parsed); const resolvedLabel = title !== null ? `#${parsed.number} - ${title}` : `#${parsed.number}`; if (view.isDestroyed) return; - const { doc, tr } = view.state; - let updated = false; - doc.descendants((node, pos) => { - if ( - node.type.name !== "mentionChip" || - node.attrs.type !== chipType || - node.attrs.id !== parsed.normalizedUrl || - node.attrs.label !== placeholderLabel - ) { - return true; - } - tr.setNodeMarkup(pos, undefined, { + const { doc } = view.state; + const range = findChipRangeById(doc, chipId); + if (!range) return; + const node = doc.nodeAt(range.from); + if (!node) return; + view.dispatch( + view.state.tr.setNodeMarkup(range.from, undefined, { ...node.attrs, label: resolvedLabel, - }); - updated = true; - return false; - }); - - if (updated) view.dispatch(tr); + }), + ); } function showPasteHint(message: string, description: string): void { @@ -487,7 +477,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { kind: "github-ref", status: "inserted", }; - void resolveGithubRefChip(view, parsedRef); + void resolveGithubRefChip(view, parsedRef, chipId); return true; } } From 48993a9d48555d2b40b3c0c00bf3665fa3e4727e Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 17:15:15 -0700 Subject: [PATCH 09/10] scope pasted text tooltip to undoable chip --- .../ui/src/features/message-editor/pasteUndoStore.ts | 11 +++++++++++ .../message-editor/tiptap/MentionChipView.tsx | 12 ++++++++++-- .../message-editor/tiptap/useTiptapEditor.ts | 12 ++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/features/message-editor/pasteUndoStore.ts diff --git a/packages/ui/src/features/message-editor/pasteUndoStore.ts b/packages/ui/src/features/message-editor/pasteUndoStore.ts new file mode 100644 index 0000000000..24e1f4b433 --- /dev/null +++ b/packages/ui/src/features/message-editor/pasteUndoStore.ts @@ -0,0 +1,11 @@ +import { create } from "zustand"; + +interface PasteUndoState { + undoableChipId: string | null; + setUndoableChipId: (chipId: string | null) => void; +} + +export const usePasteUndoStore = create((set) => ({ + undoableChipId: null, + setUndoableChipId: (chipId) => set({ undoableChipId: chipId }), +})); diff --git a/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx b/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx index 5ebebd7e2a..5b0e542264 100644 --- a/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx +++ b/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx @@ -13,6 +13,7 @@ import { import { Chip } from "@posthog/quill"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { usePasteUndoStore } from "../pasteUndoStore"; import type { ChipType, MentionChipAttrs } from "./MentionChipNode"; const chipBase = "group/chip relative top-px active:translate-y-0 pl-1"; @@ -64,6 +65,7 @@ function DefaultChip({ type, id, label, + chipId, pastedText, selected, onRemove, @@ -71,10 +73,14 @@ function DefaultChip({ type: string; id: string; label: string; + chipId: string | null; pastedText: boolean; selected: boolean; onRemove: () => void; }) { + const undoableChipId = usePasteUndoStore((state) => state.undoableChipId); + const canUndoPaste = + pastedText && chipId !== null && chipId === undoableChipId; const isCommand = type === "command"; const prefix = isCommand ? "/" : "@"; const isFile = type === "file"; @@ -100,7 +106,7 @@ function DefaultChip({ if (isFile || isFolder) { return ( - + {chipContent} ); @@ -115,7 +121,8 @@ export function MentionChipView({ editor, selected, }: NodeViewProps) { - const { type, id, label, pastedText } = node.attrs as MentionChipAttrs; + const { type, id, label, pastedText, chipId } = + node.attrs as MentionChipAttrs; const handleRemove = () => { const pos = getPos(); @@ -133,6 +140,7 @@ export function MentionChipView({ type={type} id={id} label={label} + chipId={chipId ?? null} pastedText={pastedText} selected={selected} onRemove={handleRemove} diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 4e2b875488..b1a0c179b8 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -29,6 +29,7 @@ import { useEditor } from "@tiptap/react"; import type React from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { getGithubIssue, getGithubPullRequest } from "../hostApi"; +import { usePasteUndoStore } from "../pasteUndoStore"; import { usePromptHistoryStore } from "../promptHistoryStore"; import { findChipRangeById } from "../tiptap/chipRange"; import { getEditorExtensions } from "../tiptap/extensions"; @@ -264,6 +265,13 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const lastAutoConvertedPasteRef = useRef( null, ); + useEffect(() => { + return () => { + if (lastAutoConvertedPasteRef.current) { + usePasteUndoStore.getState().setUndoableChipId(null); + } + }; + }, []); const historyActions = usePromptHistoryStore.getState(); const [isEmptyState, setIsEmptyState] = useState(true); const [isReady, setIsReady] = useState(false); @@ -409,6 +417,9 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { // Only the immediately-following paste can undo an auto-conversion. const lastConverted = lastAutoConvertedPasteRef.current; lastAutoConvertedPasteRef.current = null; + if (lastConverted) { + usePasteUndoStore.getState().setUndoableChipId(null); + } // Auto-wrap selected text as markdown link when pasting a URL if ( @@ -539,6 +550,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { status: "pending", }; lastAutoConvertedPasteRef.current = tracked; + usePasteUndoStore.getState().setUndoableChipId(tracked.chipId); (async () => { try { From fb99fd2dad69d585bf98b8f3bb3afe5b3ca34f9e Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 17:15:50 -0700 Subject: [PATCH 10/10] remove unused readAbsoluteFile wrapper --- packages/ui/src/features/message-editor/hostApi.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/ui/src/features/message-editor/hostApi.ts b/packages/ui/src/features/message-editor/hostApi.ts index d4faef532d..6cd6085552 100644 --- a/packages/ui/src/features/message-editor/hostApi.ts +++ b/packages/ui/src/features/message-editor/hostApi.ts @@ -72,12 +72,6 @@ export function getGhStatus(): Promise { return hostClient().git.getGhStatus.query(); } -export function readAbsoluteFile(input: { - filePath: string; -}): Promise { - return hostClient().fs.readAbsoluteFile.query(input); -} - export function selectDirectory(): Promise { return hostClient().os.selectDirectory.query(); }