Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
51 changes: 51 additions & 0 deletions packages/core/src/message-editor/paste.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
15 changes: 15 additions & 0 deletions packages/core/src/message-editor/paste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
6 changes: 0 additions & 6 deletions packages/ui/src/features/message-editor/hostApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,6 @@ export function getGhStatus(): Promise<GhStatus> {
return hostClient().git.getGhStatus.query();
}

export function readAbsoluteFile(input: {
filePath: string;
}): Promise<string | null> {
return hostClient().fs.readAbsoluteFile.query(input);
}

export function selectDirectory(): Promise<string | null> {
return hostClient().os.selectDirectory.query();
}
Expand Down
11 changes: 11 additions & 0 deletions packages/ui/src/features/message-editor/pasteUndoStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { create } from "zustand";

interface PasteUndoState {
undoableChipId: string | null;
setUndoableChipId: (chipId: string | null) => void;
}

export const usePasteUndoStore = create<PasteUndoState>((set) => ({
undoableChipId: null,
setUndoableChipId: (chipId) => set({ undoableChipId: chipId }),
}));
42 changes: 13 additions & 29 deletions packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down Expand Up @@ -103,39 +104,22 @@ export const MentionChipNode = Node.create({
replaceMentionChipById:
(chipId: string, attrs: Partial<MentionChipAttrs>) =>
({ 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;
},
};
},
Expand Down
99 changes: 24 additions & 75 deletions packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,9 @@ 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 { usePasteUndoStore } from "../pasteUndoStore";
import type { ChipType, MentionChipAttrs } from "./MentionChipNode";

const chipBase = "group/chip relative top-px active:translate-y-0 pl-1";
Expand Down Expand Up @@ -68,15 +65,22 @@ function DefaultChip({
type,
id,
label,
chipId,
pastedText,
selected,
onRemove,
}: {
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";
Expand All @@ -101,69 +105,24 @@ function DefaultChip({
);

if (isFile || isFolder) {
return <Tooltip content={id}>{chipContent}</Tooltip>;
return (
<Tooltip content={canUndoPaste ? "Paste again to expand as text" : id}>
{chipContent}
</Tooltip>
);
}

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 (
<Tooltip content="Click to paste as text instead">
<Chip
size="xs"
contentEditable={false}
onClick={handleClick}
className={`${chipBase} cli-file-mention cursor-pointer! ${selected ? selectedRing : ""}`}
>
<IconCloseButton type="file" onRemove={onRemove} />@{label}
</Chip>
</Tooltip>
);
}

export function MentionChipView({
node,
getPos,
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();
Expand All @@ -177,25 +136,15 @@ export function MentionChipView({

return (
<NodeViewWrapper as="span" className="inline">
{pastedText ? (
<PastedTextChip
label={label}
filePath={id}
editor={editor}
node={node}
getPos={getPos}
selected={selected}
onRemove={handleRemove}
/>
) : (
<DefaultChip
type={type}
id={id}
label={label}
selected={selected}
onRemove={handleRemove}
/>
)}
<DefaultChip
type={type}
id={id}
label={label}
chipId={chipId ?? null}
pastedText={pastedText}
selected={selected}
onRemove={handleRemove}
/>
</NodeViewWrapper>
);
}
75 changes: 75 additions & 0 deletions packages/ui/src/features/message-editor/tiptap/chipRange.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading