Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
feat(chat): highlight-to-chat — reference file/table selections in Chat#6087
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
5c4454b3dac5a81551f9627dbd176421994a80f2e28455da9598791e7d3d58df82c44e9a1da09231e0a0b20cfcd2e5f8f5eff18e6d939aead3b68781392a7494eab0d14b9ba60ff8c7ffafe01865ddf42dac8313db183b53bb5d6c714e6d87644b8dFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -5,13 +5,20 @@ import type { OnMount } from '@monaco-editor/react' | ||
| import { cn } from '@sim/emcn' | ||
| import type { editor as MonacoEditorTypes } from 'monaco-editor' | ||
| import dynamic from 'next/dynamic' | ||
| import { | ||
| buildFileSelectionLabel, | ||
| truncateSelectionText, | ||
| } from '@/lib/copilot/chat/selection-context' | ||
| import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' | ||
| import { getFileExtension } from '@/lib/uploads/utils/file-utils' | ||
| import { useAddToChat } from '@/hooks/use-add-to-chat' | ||
| import type { ChatContext } from '@/stores/panel' | ||
| import { EditorContextMenu } from './editor-context-menu' | ||
| import type { PreviewMode } from './file-viewer' | ||
| import { PreviewPanel, resolvePreviewType } from './preview-panel' | ||
| import { PreviewLoadingFrame } from './preview-shared' | ||
| import { useEditableFileContent } from './use-editable-file-content' | ||
| import { useSelectionCopyBridge } from './use-selection-copy-bridge' | ||
| const SIM_DARK_RULES: MonacoEditorTypes.ITokenThemeRule[] = [ | ||
| { token: 'comment', foreground: '606060', fontStyle: 'italic' }, | ||
| @@ -373,6 +380,38 @@ export const TextEditor = memo(function TextEditor({ | ||
| const monacoLanguage = resolveMonacoLanguage(file) | ||
| const monacoTheme = useMonacoTheme() | ||
| const addToChat = useAddToChat() | ||
| const buildSelectionContext = useCallback((): ChatContext | null => { | ||
| 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, | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| }, [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( | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HTMLDivElement | null> | ||
| let buildContext: ReturnType<typeof vi.fn> | ||
| /** | ||
| * 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 ( | ||
| <div ref={containerRef}> | ||
| <textarea id='editor-surface' /> | ||
| <input id='find-box' /> | ||
| </div> | ||
| ) | ||
| } | ||
| /** Dispatches a bubbling copy from `id` and returns what was written. */ | ||
| function dispatchCopy(id: string): Record<string, string> { | ||
| const written: Record<string, string> = {} | ||
| const event = new Event('copy', { bubbles: true }) as ClipboardEvent | ||
| Object.defineProperty(event, 'clipboardData', { | ||
| value: { | ||
| setData: (type: string, value: string) => { | ||
| written[type] = value | ||
| }, | ||
| }, | ||
| }) | ||
| act(() => { | ||
| container.querySelector(`#${id}`)?.dispatchEvent(event) | ||
| }) | ||
| return written | ||
| } | ||
| describe('useSelectionCopyBridge', () => { | ||
| beforeEach(() => { | ||
| container = document.createElement('div') | ||
| document.body.appendChild(container) | ||
| containerRef = createRef<HTMLDivElement>() | ||
| buildContext = vi.fn(() => selection) | ||
| root = createRoot(container) | ||
| act(() => { | ||
| root.render(<Host />) | ||
| }) | ||
| }) | ||
| afterEach(() => { | ||
| act(() => root.unmount()) | ||
| container.remove() | ||
| vi.clearAllMocks() | ||
| }) | ||
| it('attaches the selection when copying from the editor surface', () => { | ||
| const written = dispatchCopy('editor-surface') | ||
| expect(buildContext).toHaveBeenCalled() | ||
| expect(written[SIM_SELECTION_MIME]).toContain('file_selection') | ||
| }) | ||
| it('ignores a copy from a nested input such as the find box', () => { | ||
| // The document still holds a highlight, so without the guard the chip would | ||
| // ride onto text the user never copied. | ||
| const written = dispatchCopy('find-box') | ||
| expect(buildContext).not.toHaveBeenCalled() | ||
| expect(written[SIM_SELECTION_MIME]).toBeUndefined() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| 'use client' | ||
| import { type RefObject, useEffect } from 'react' | ||
| import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection-clipboard' | ||
| import type { ChatContext } from '@/stores/panel' | ||
| /** | ||
| * Rides a selection {@link ChatContext} onto the editor's native copy so a | ||
| * highlighted passage copied with Cmd+C pastes into Chat as a reference chip. | ||
| * | ||
| * Attached in the BUBBLE phase so it runs after the inner editor's own copy | ||
| * handler — Monaco and ProseMirror both `clearData()` before writing | ||
| * `text/plain`, so the custom type must be added last to survive. | ||
| * | ||
| * @param buildContext - Returns null when there is no non-empty selection. | ||
| * @param enabled - Re-runs the effect for a container that mounts late (behind a | ||
| * loading gate); a ref isn't reactive, so the effect would otherwise bail on the | ||
| * first render and never re-attach. | ||
| */ | ||
| export function useSelectionCopyBridge( | ||
| containerRef: RefObject<HTMLElement | null>, | ||
| buildContext: () => ChatContext | null, | ||
| enabled = true | ||
| ): void { | ||
| useEffect(() => { | ||
| const dom = containerRef.current | ||
| if (!dom || !enabled) return | ||
| const onCopy = (e: ClipboardEvent) => { | ||
| // A copy from a field nested in the editor — Monaco's find box being the | ||
| // common one — bubbles here while the document still holds a highlight, | ||
| // so the selection would be attached to text the user never copied. | ||
| // | ||
| // Only INPUT is skipped, deliberately: Monaco's own editing surface is a | ||
| // hidden TEXTAREA, so excluding textareas (as the table grid does, where | ||
| // the cell editors really are form fields) would suppress the chip on the | ||
| // main copy path this hook exists for. | ||
| if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return | ||
| const context = buildContext() | ||
| if (context) attachSelectionContextToClipboard(e.clipboardData, context) | ||
| } | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| dom.addEventListener('copy', onCopy) | ||
| return () => dom.removeEventListener('copy', onCopy) | ||
| }, [containerRef, buildContext, enabled]) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.