diff --git a/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts b/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts index ef4540fe39..7552ad433d 100644 --- a/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts +++ b/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts @@ -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 { mentionChip: { @@ -40,13 +45,20 @@ declare module "@tiptap/core" { } } -export const MentionChipNode = Node.create({ +export const MentionChipNode = Node.create({ name: "mentionChip", group: "inline", inline: true, selectable: true, atom: true, + addOptions() { + return { + getPastedText: () => null, + forgetPastedText: () => {}, + }; + }, + addAttributes() { return { type: { default: "file" as ChipType }, diff --git a/packages/ui/src/features/message-editor/tiptap/MentionChipView.test.tsx b/packages/ui/src/features/message-editor/tiptap/MentionChipView.test.tsx new file mode 100644 index 0000000000..5830de24a0 --- /dev/null +++ b/packages/ui/src/features/message-editor/tiptap/MentionChipView.test.tsx @@ -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} + + + ), +})); + +vi.mock("@posthog/ui/primitives/Tooltip", () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => children, +})); + +vi.mock("@tiptap/react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + NodeViewWrapper: ({ children }: React.PropsWithChildren) => ( + {children} + ), + }; +}); + +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( + 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"); + }); +}); diff --git a/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx b/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx index 5b0e542264..f37c4e39bd 100644 --- a/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx +++ b/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx @@ -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"; @@ -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 = @@ -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 = ( 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 : ""}`} > {isGithubRef ? ( @@ -105,11 +121,12 @@ function DefaultChip({ ); if (isFile || isFolder) { - return ( - - {chipContent} - - ); + const tooltip = canExpandPastedText + ? canUndoPaste + ? "Click or paste again to expand as text" + : "Click to expand as text" + : id; + return {chipContent}; } return chipContent; @@ -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(); @@ -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 ( @@ -142,8 +186,10 @@ export function MentionChipView({ label={label} chipId={chipId ?? null} pastedText={pastedText} + canExpandPastedText={canExpandPastedText} selected={selected} onRemove={handleRemove} + onExpandPastedText={handleExpandPastedText} /> ); diff --git a/packages/ui/src/features/message-editor/tiptap/extensions.ts b/packages/ui/src/features/message-editor/tiptap/extensions.ts index 64330e77ba..1eeb80c413 100644 --- a/packages/ui/src/features/message-editor/tiptap/extensions.ts +++ b/packages/ui/src/features/message-editor/tiptap/extensions.ts @@ -1,9 +1,10 @@ +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; @@ -11,6 +12,8 @@ export interface EditorExtensionsOptions { fileMentions?: boolean; issueMentions?: boolean; commands?: boolean; + getPastedText?: MentionChipOptions["getPastedText"]; + forgetPastedText?: MentionChipOptions["forgetPastedText"]; } export function getEditorExtensions(options: EditorExtensionsOptions) { @@ -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, @@ -37,7 +42,7 @@ export function getEditorExtensions(options: EditorExtensionsOptions) { code: false, }), Placeholder.configure({ placeholder }), - MentionChipNode, + MentionChipNode.configure({ getPastedText, forgetPastedText }), ]; if (fileMentions) { diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 1b11f8bbb8..0d4cda2282 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -114,11 +114,13 @@ async function pasteTextAsFile( text: string, pasteCountRef: React.MutableRefObject, tracked?: TrackedAutoConvertedPaste, + rememberPastedText?: (chipId: string, text: string) => void, ): Promise { 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, @@ -319,6 +321,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const lastAutoConvertedPasteRef = useRef( null, ); + const pastedTextByChipIdRef = useRef(new Map()); useEffect(() => { return () => { if (lastAutoConvertedPasteRef.current) { @@ -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, @@ -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"); } @@ -549,6 +574,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { ) ) { event.preventDefault(); + pastedTextByChipIdRef.current.delete(lastConverted.chipId); if (lastConverted.kind === "file") { useFeatureSettingsStore .getState() @@ -652,6 +678,9 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { effectiveText, pasteCountRef, tracked, + (chipId, text) => { + pastedTextByChipIdRef.current.set(chipId, text); + }, ); if (tracked.status !== "canceled") { showPasteHint( @@ -780,6 +809,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { editor.commands.clearContent(); prevBashModeRef.current = false; pasteCountRef.current = 0; + pastedTextByChipIdRef.current.clear(); setAttachments([]); draft.clearDraft(); }; @@ -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]);