diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx index 904d3c06a11..3cfd6c0e228 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx @@ -8,7 +8,7 @@ import { DropdownMenuShortcut, DropdownMenuTrigger, } from '@sim/emcn' -import { Clipboard, Duplicate, Search, SelectAll } from '@sim/emcn/icons' +import { Blimp, Clipboard, Duplicate, Search, SelectAll } from '@sim/emcn/icons' import { Scissors } from 'lucide-react' interface EditorContextMenuProps { @@ -23,6 +23,8 @@ interface EditorContextMenuProps { onPaste: () => void onSelectAll: () => void onFind: () => void + /** Adds the current selection to Chat as a reference. Omit to hide the item. */ + onAddToChat?: () => void } export function EditorContextMenu({ @@ -37,6 +39,7 @@ export function EditorContextMenu({ onPaste, onSelectAll, onFind, + onAddToChat, }: EditorContextMenuProps) { return ( !open && onClose()} modal={false}> @@ -60,6 +63,15 @@ export function EditorContextMenu({ sideOffset={2} onCloseAutoFocus={(e) => e.preventDefault()} > + {onAddToChat && ( + <> + + + Add to Chat + + + + )} {canEdit && ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx index 56b4dd78389..ca2acb30e0d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { Blimp } from '@sim/emcn/icons' import { posToDOMRect } from '@tiptap/core' import { PluginKey } from '@tiptap/pm/state' import type { Editor } from '@tiptap/react' @@ -54,6 +55,8 @@ interface EditorBubbleMenuProps { editor: Editor /** The editor's scrollable viewport, used to keep the toolbar on-screen for selections taller than it. */ scrollContainerRef: React.RefObject + /** Adds the current selection to Chat as a reference. Omit to hide the action. */ + onAddToChat?: () => void } /** @@ -62,7 +65,11 @@ interface EditorBubbleMenuProps { * live in the `/` slash menu. Active states are read through {@link useEditorState} so the bar * stays correct without re-rendering the editor on every transaction. */ -export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMenuProps) { +export function EditorBubbleMenu({ + editor, + scrollContainerRef, + onAddToChat, +}: EditorBubbleMenuProps) { const [linkValue, setLinkValue] = useState(null) const linkInputRef = useRef(null) const linkRangeRef = useRef<{ from: number; to: number } | null>(null) @@ -243,6 +250,17 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen ) : ( <> + {onAddToChat && ( + <> + + + + )} > label: string shortcut?: string isActive?: boolean diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 5cd2f43dd3a..ffb118973d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -12,14 +12,21 @@ import type { Editor } from '@tiptap/react' import { EditorContent, useEditor } from '@tiptap/react' import { useRouter } from 'next/navigation' import { useSession } from '@/lib/auth/auth-client' +import { + buildFileSelectionLabel, + truncateSelectionText, +} from '@/lib/copilot/chat/selection-context' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref' import { isUntitledName } from '@/app/workspace/[workspaceId]/files/untitled-title' import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files' +import { useAddToChat } from '@/hooks/use-add-to-chat' import type { SaveStatus } from '@/hooks/use-autosave' import { useFileContentSource } from '@/hooks/use-file-content-source' +import type { ChatContext } from '@/stores/panel' import { PreviewLoadingFrame } from '../preview-shared' import { useEditableFileContent } from '../use-editable-file-content' +import { useSelectionCopyBridge } from '../use-selection-copy-bridge' import { announceAgentApplying, clearAgentApplying, @@ -1124,6 +1131,36 @@ export function LoadedRichMarkdownEditor({ [] ) + const addToChat = useAddToChat() + /** + * No line range: this editor renders a ProseMirror document, whose block + * boundaries do not correspond to markdown source lines (blank lines between + * paragraphs, list markers, heading prefixes and fenced blocks all shift the + * real line). Reporting a derived count would label the chip — and prompt the + * agent — with line numbers that don't exist in the file. + */ + const buildSelectionContext = useCallback((): ChatContext | null => { + if (!editor) return null + const { from, to } = editor.state.selection + if (from === to) return null + const text = editor.state.doc.textBetween(from, to, '\n') + if (!text.trim()) return null + return { + kind: 'file_selection', + fileId: file.id, + fileName: file.name, + label: buildFileSelectionLabel(file.name), + text: truncateSelectionText(text), + } + }, [editor, file.id, file.name]) + + const handleAddSelectionToChat = () => { + const context = buildSelectionContext() + if (context) addToChat(context) + } + + useSelectionCopyBridge(containerRef, buildSelectionContext) + // Show the read-only placeholder (the already-fetched markdown) whenever a collaborative doc has not yet // seeded — including during an agent stream that begins before the seed lands. Streamed diffs are held // until `collabReady` (see the streaming effect), so before then the editor is empty; the placeholder @@ -1135,7 +1172,13 @@ export function LoadedRichMarkdownEditor({ ref={containerRef} className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')} > - {editor && } + {editor && ( + + )} {editor && } {editor && } { + const editor = monacoEditorRef.current + const sel = editor?.getSelection() + const model = editor?.getModel() + if (!editor || !sel || sel.isEmpty() || !model) return null + const text = model.getValueInRange(sel) + if (!text.trim()) return null + const startLine = sel.startLineNumber + // A full-line highlight ends at column 1 of the FOLLOWING line, so that line + // contributed no text — reporting it would claim a range one line longer + // than what was selected, in both the chip label and the agent's prompt. + const endLine = + sel.endColumn === 1 && sel.endLineNumber > startLine + ? sel.endLineNumber - 1 + : sel.endLineNumber + return { + kind: 'file_selection', + fileId: file.id, + fileName: file.name, + label: buildFileSelectionLabel(file.name, startLine, endLine), + text: truncateSelectionText(text), + startLine, + endLine, + } + }, [file.id, file.name]) + + const handleAddSelectionToChat = () => { + const context = buildSelectionContext() + if (context) addToChat(context) + } const { content, @@ -394,6 +433,10 @@ export const TextEditor = memo(function TextEditor({ }) contentRef.current = content + // Enable once content has loaded — the container (and Monaco) only mount after + // the `isContentLoading` early return below, so the bridge must (re-)attach then. + useSelectionCopyBridge(containerRef, buildSelectionContext, !isContentLoading) + useEffect(() => { const editor = monacoEditorRef.current if (!editor) return @@ -650,6 +693,10 @@ export const TextEditor = memo(function TextEditor({ onClose={closeContextMenu} hasSelection={contextMenu.hasSelection} canEdit={!isEditorReadOnly} + onAddToChat={() => { + handleAddSelectionToChat() + closeContextMenu() + }} onCut={() => { monacoEditorRef.current?.focus() monacoEditorRef.current?.trigger( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.test.tsx new file mode 100644 index 00000000000..b457be62f2a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.test.tsx @@ -0,0 +1,88 @@ +/** + * @vitest-environment jsdom + */ +import { act, createRef, type RefObject } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SIM_SELECTION_MIME } from '@/lib/copilot/chat/selection-clipboard' +import type { ChatContext } from '@/stores/panel' +import { useSelectionCopyBridge } from './use-selection-copy-bridge' + +const selection: ChatContext = { + kind: 'file_selection', + fileId: 'f1', + fileName: 'notes.md', + label: 'notes.md:2-4', + text: 'the exact passage', +} + +let container: HTMLDivElement +let root: Root +let containerRef: RefObject +let buildContext: ReturnType + +/** + * Mirrors the editors this hook wraps: Monaco's editing surface is a hidden + * textarea, and its find widget is a real input nested in the same container. + */ +function Host() { + useSelectionCopyBridge(containerRef, buildContext as () => ChatContext | null) + return ( +
+