Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export interface MentionChipAttrs {
skillName?: string;
}

export interface MentionChipOptions {
getPastedText: (chipId: string) => string | null;
forgetPastedText: (chipId: string) => void;
}

declare module "@tiptap/core" {
interface Commands<ReturnType> {
mentionChip: {
Expand All @@ -40,13 +45,20 @@ declare module "@tiptap/core" {
}
}

export const MentionChipNode = Node.create({
export const MentionChipNode = Node.create<MentionChipOptions>({
name: "mentionChip",
group: "inline",
inline: true,
selectable: true,
atom: true,

addOptions() {
return {
getPastedText: () => null,
forgetPastedText: () => {},
};
},

addAttributes() {
return {
type: { default: "file" as ChipType },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { NodeViewProps } from "@tiptap/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { usePasteUndoStore } from "../pasteUndoStore";
import { MentionChipView } from "./MentionChipView";

vi.mock("@posthog/quill", () => ({
Chip: ({
children,
onClick,
}: React.PropsWithChildren<{ onClick?: () => void }>) => (
<>
{children}
<button type="button" onClick={onClick}>
Expand pasted text
</button>
</>
),
}));

vi.mock("@posthog/ui/primitives/Tooltip", () => ({
Tooltip: ({ children }: React.PropsWithChildren) => children,
}));

vi.mock("@tiptap/react", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tiptap/react")>();
return {
...actual,
NodeViewWrapper: ({ children }: React.PropsWithChildren) => (
<span>{children}</span>
),
};
});

describe("MentionChipView", () => {
beforeEach(() => {
usePasteUndoStore.getState().setUndoableChipId(null);
});

it("expands pasted text when the chip is clicked", () => {
const content = "first line\nsecond line";
const forgetPastedText = vi.fn();
const insertText = vi.fn();
const chain = {
focus: vi.fn(),
command: vi.fn(),
run: vi.fn(),
};
chain.focus.mockReturnValue(chain);
chain.command.mockImplementation(
(
callback: (props: { tr: { insertText: typeof insertText } }) => boolean,
) => {
callback({ tr: { insertText } });
return chain;
},
);
chain.run.mockReturnValue(true);

render(
<MentionChipView
{...({
node: {
attrs: {
type: "file",
id: "/tmp/pasted.txt",
label: "Pasted text #1 (2 lines)",
pastedText: true,
chipId: "paste-1",
},
nodeSize: 1,
},
getPos: () => 4,
editor: { chain: () => chain },
extension: {
options: {
getPastedText: () => content,
forgetPastedText,
},
},
selected: false,
} as unknown as NodeViewProps)}
/>,
);

fireEvent.click(screen.getByRole("button", { name: "Expand pasted text" }));

expect(insertText).toHaveBeenCalledWith(content, 4, 5);
expect(chain.run).toHaveBeenCalled();
expect(forgetPastedText).toHaveBeenCalledWith("paste-1");
});
});
62 changes: 54 additions & 8 deletions packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@ 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 NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { usePasteUndoStore } from "../pasteUndoStore";
import type { ChipType, MentionChipAttrs } from "./MentionChipNode";
import type {
ChipType,
MentionChipAttrs,
MentionChipOptions,
} from "./MentionChipNode";

const chipBase = "group/chip relative top-px active:translate-y-0 pl-1";

Expand Down Expand Up @@ -67,16 +72,20 @@ function DefaultChip({
label,
chipId,
pastedText,
canExpandPastedText,
selected,
onRemove,
onExpandPastedText,
}: {
type: string;
id: string;
label: string;
chipId: string | null;
pastedText: boolean;
canExpandPastedText: boolean;
selected: boolean;
onRemove: () => void;
onExpandPastedText: () => void;
}) {
const undoableChipId = usePasteUndoStore((state) => state.undoableChipId);
const canUndoPaste =
Expand All @@ -87,13 +96,20 @@ function DefaultChip({
const isFolder = type === "folder";
const isGithubRef = type === "github_issue" || type === "github_pr";
const canOpenUrl = isGithubRef && /^https:\/\//.test(id);
const isClickable = canOpenUrl || canExpandPastedText;

const chipContent = (
<Chip
size="xs"
contentEditable={false}
onClick={canOpenUrl ? () => window.open(id, "_blank") : undefined}
className={`${chipBase} max-w-full whitespace-nowrap ${isGithubRef ? "cursor-pointer!" : "cursor-default! active:translate-y-0!"} ${isCommand ? "cli-slash-command" : "cli-file-mention"} ${selected ? selectedRing : ""}`}
onClick={
canOpenUrl
? () => window.open(id, "_blank")
: canExpandPastedText
? onExpandPastedText
: undefined
}
className={`${chipBase} max-w-full whitespace-nowrap ${isClickable ? "cursor-pointer!" : "cursor-default! active:translate-y-0!"} ${isCommand ? "cli-slash-command" : "cli-file-mention"} ${selected ? selectedRing : ""}`}
>
<IconCloseButton type={type as ChipType} onRemove={onRemove} />
{isGithubRef ? (
Expand All @@ -105,11 +121,12 @@ function DefaultChip({
);

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

return chipContent;
Expand All @@ -119,10 +136,15 @@ export function MentionChipView({
node,
getPos,
editor,
extension,
selected,
}: NodeViewProps) {
const { type, id, label, pastedText, chipId } =
node.attrs as MentionChipAttrs;
const { getPastedText, forgetPastedText } =
extension.options as MentionChipOptions;
const canExpandPastedText =
pastedText && chipId != null && getPastedText(chipId) !== null;

const handleRemove = () => {
const pos = getPos();
Expand All @@ -132,6 +154,28 @@ export function MentionChipView({
.focus()
.deleteRange({ from: pos, to: pos + node.nodeSize })
.run();
if (chipId) forgetPastedText(chipId);
};

const handleExpandPastedText = () => {
if (!chipId) return;
const content = getPastedText(chipId);
if (content === null) return;

const pos = getPos();
if (pos == null) return;

editor
.chain()
.focus()
.command(({ tr }) => {
tr.insertText(content, pos, pos + node.nodeSize);
return true;
})
.run();
forgetPastedText(chipId);
usePasteUndoStore.getState().setUndoableChipId(null);
useFeatureSettingsStore.getState().markHintLearned("paste-as-file");
};

return (
Expand All @@ -142,8 +186,10 @@ export function MentionChipView({
label={label}
chipId={chipId ?? null}
pastedText={pastedText}
canExpandPastedText={canExpandPastedText}
selected={selected}
onRemove={handleRemove}
onExpandPastedText={handleExpandPastedText}
/>
</NodeViewWrapper>
);
Expand Down
11 changes: 8 additions & 3 deletions packages/ui/src/features/message-editor/tiptap/extensions.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import type { AnyExtension } from "@tiptap/core";
import Placeholder from "@tiptap/extension-placeholder";
import StarterKit from "@tiptap/starter-kit";
import { createCommandMention } from "./CommandMention";
import { createFileMention } from "./FileMention";
import { createIssueMention } from "./IssueMention";
import { MentionChipNode } from "./MentionChipNode";
import { MentionChipNode, type MentionChipOptions } from "./MentionChipNode";

export interface EditorExtensionsOptions {
sessionId: string;
placeholder?: string;
fileMentions?: boolean;
issueMentions?: boolean;
commands?: boolean;
getPastedText?: MentionChipOptions["getPastedText"];
forgetPastedText?: MentionChipOptions["forgetPastedText"];
}

export function getEditorExtensions(options: EditorExtensionsOptions) {
Expand All @@ -20,9 +23,11 @@ export function getEditorExtensions(options: EditorExtensionsOptions) {
fileMentions = true,
issueMentions = true,
commands = true,
getPastedText = () => null,
forgetPastedText = () => {},
} = options;

const extensions = [
const extensions: AnyExtension[] = [
StarterKit.configure({
heading: false,
blockquote: false,
Expand All @@ -37,7 +42,7 @@ export function getEditorExtensions(options: EditorExtensionsOptions) {
code: false,
}),
Placeholder.configure({ placeholder }),
MentionChipNode,
MentionChipNode.configure({ getPastedText, forgetPastedText }),
];

if (fileMentions) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,13 @@ async function pasteTextAsFile(
text: string,
pasteCountRef: React.MutableRefObject<number>,
tracked?: TrackedAutoConvertedPaste,
rememberPastedText?: (chipId: string, text: string) => void,
): Promise<void> {
const result = await persistTextContent(text);
if (tracked?.status === "canceled") return;
pasteCountRef.current += 1;
const lineCount = text.split("\n").length;
if (tracked) rememberPastedText?.(tracked.chipId, text);
insertChipWithTrailingSpace(view, {
type: "file",
id: result.path,
Expand Down Expand Up @@ -319,6 +321,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
const lastAutoConvertedPasteRef = useRef<TrackedAutoConvertedPaste | null>(
null,
);
const pastedTextByChipIdRef = useRef(new Map<string, string>());
useEffect(() => {
return () => {
if (lastAutoConvertedPasteRef.current) {
Expand All @@ -339,6 +342,11 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
placeholder,
fileMentions,
commands,
getPastedText: (chipId) =>
pastedTextByChipIdRef.current.get(chipId) ?? null,
forgetPastedText: (chipId) => {
pastedTextByChipIdRef.current.delete(chipId);
},
}),
editable: !disabled,
autofocus: autoFocus ? "end" : false,
Expand Down Expand Up @@ -368,7 +376,24 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
useFeatureSettingsStore
.getState()
.markHintLearned("paste-inline");
await pasteTextAsFile(view, text, pasteCountRef);
const tracked: TrackedAutoConvertedPaste = {
clipboardText: text,
insertText: text,
chipId: crypto.randomUUID(),
kind: "file",
status: "pending",
};
lastAutoConvertedPasteRef.current = tracked;
usePasteUndoStore.getState().setUndoableChipId(tracked.chipId);
await pasteTextAsFile(
view,
text,
pasteCountRef,
tracked,
(chipId, pastedText) => {
pastedTextByChipIdRef.current.set(chipId, pastedText);
},
);
} catch (_error) {
toast.error("Failed to paste as file attachment");
}
Expand Down Expand Up @@ -549,6 +574,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
)
) {
event.preventDefault();
pastedTextByChipIdRef.current.delete(lastConverted.chipId);
if (lastConverted.kind === "file") {
useFeatureSettingsStore
.getState()
Expand Down Expand Up @@ -652,6 +678,9 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
effectiveText,
pasteCountRef,
tracked,
(chipId, text) => {
pastedTextByChipIdRef.current.set(chipId, text);
},
);
if (tracked.status !== "canceled") {
showPasteHint(
Expand Down Expand Up @@ -780,6 +809,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
editor.commands.clearContent();
prevBashModeRef.current = false;
pasteCountRef.current = 0;
pastedTextByChipIdRef.current.clear();
setAttachments([]);
draft.clearDraft();
};
Expand Down Expand Up @@ -832,6 +862,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
const clear = useCallback(() => {
editor?.commands.clearContent();
prevBashModeRef.current = false;
pastedTextByChipIdRef.current.clear();
setAttachments([]);
draft.clearDraft();
}, [editor, draft]);
Expand Down
Loading