Skip to content
Open
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 @@ -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 {
Expand All @@ -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({
Expand All @@ -37,6 +39,7 @@ export function EditorContextMenu({
onPaste,
onSelectAll,
onFind,
onAddToChat,
}: EditorContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
Expand All @@ -60,6 +63,15 @@ export function EditorContextMenu({
sideOffset={2}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{onAddToChat && (
<>
<DropdownMenuItem disabled={!hasSelection} onSelect={onAddToChat}>
<Blimp />
Add to chat
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{canEdit && (
<DropdownMenuItem disabled={!hasSelection} onSelect={onCut}>
<Scissors />
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<HTMLDivElement | null>
/** Adds the current selection to Chat as a reference. Omit to hide the action. */
onAddToChat?: () => void
}

/**
Expand All @@ -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<string | null>(null)
const linkInputRef = useRef<HTMLInputElement>(null)
const linkRangeRef = useRef<{ from: number; to: number } | null>(null)
Expand Down Expand Up @@ -243,6 +250,17 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
</>
) : (
<>
{onAddToChat && (
<>
<ToolbarButton
icon={Blimp}
label='Add to chat'
isActive={false}
onClick={onAddToChat}
/>
<ToolbarDivider />
</>
)}
<ToolbarButton
icon={Bold}
label='Bold'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { ComponentType, SVGProps } from 'react'
import { cn, Tooltip } from '@sim/emcn'
import type { LucideIcon } from 'lucide-react'

interface ToolbarButtonProps {
icon: LucideIcon
/** Any SVG icon component — Lucide icons and `@sim/emcn/icons` both satisfy this. */
icon: ComponentType<SVGProps<SVGSVGElement>>
label: string
shortcut?: string
isActive?: boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
}
Comment thread
cursor[bot] marked this conversation as resolved.
}, [editor, file.id, file.name])

const handleAddSelectionToChat = useCallback(() => {
const context = buildSelectionContext()
if (context) addToChat(context)
}, [addToChat, buildSelectionContext])

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
Expand All @@ -1135,7 +1172,13 @@ export function LoadedRichMarkdownEditor({
ref={containerRef}
className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')}
>
{editor && <EditorBubbleMenu editor={editor} scrollContainerRef={containerRef} />}
{editor && (
<EditorBubbleMenu
editor={editor}
scrollContainerRef={containerRef}
onAddToChat={handleAddSelectionToChat}
/>
)}
{editor && <TableBubbleMenu editor={editor} scrollContainerRef={containerRef} />}
{editor && <LinkHoverCard editor={editor} />}
<input
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -373,6 +380,32 @@ 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
const endLine = 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 = useCallback(() => {
const context = buildSelectionContext()
if (context) addToChat(context)
}, [addToChat, buildSelectionContext])

const {
content,
Expand All @@ -394,6 +427,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
Expand Down Expand Up @@ -650,6 +687,10 @@ export const TextEditor = memo(function TextEditor({
onClose={closeContextMenu}
hasSelection={contextMenu.hasSelection}
canEdit={!isEditorReadOnly}
onAddToChat={() => {
handleAddSelectionToChat()
closeContextMenu()
}}
onCut={() => {
monacoEditorRef.current?.focus()
monacoEditorRef.current?.trigger(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'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) => {
const context = buildContext()
if (context) attachSelectionContextToClipboard(e.clipboardData, context)
}
dom.addEventListener('copy', onCopy)
return () => dom.removeEventListener('copy', onCopy)
}, [containerRef, buildContext, enabled])
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,26 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record<ChatContextKind, ChatContextKind
label: 'Table',
renderIcon: ({ className }) => <TableIcon className={className} />,
},
table_selection: {
label: 'Table selection',
renderIcon: ({ className }) => <TableIcon className={className} />,
},
file: {
label: 'File',
renderIcon: ({ context, className }) => {
const FileDocIcon = getDocumentIcon('', context.label)
return <FileDocIcon className={className} />
},
},
file_selection: {
label: 'File selection',
renderIcon: ({ context, className }) => {
// The label carries a `:line` suffix, so read the extension off the file
// name the context carries — `getDocumentIcon` needs `md`, not `md:12-40`.
const FileDocIcon = getDocumentIcon('', context.fileName ?? context.label)
return <FileDocIcon className={className} />
},
},
Comment thread
mzxchandra marked this conversation as resolved.
folder: {
label: 'Folder',
renderIcon: ({ className }) => <FolderIcon className={className} />,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,13 @@ interface ChatSurfaceContextValue {
userId?: string
/** Notifies the surface owner that a context chip was added to the input. */
onContextAdd: (context: ChatContext) => void
/** Notifies the surface owner that a context chip was removed from the input. */
onContextRemove: (context: ChatContext) => void
/**
* Notifies the surface owner that a context chip was removed from the input.
* `remaining` is the input's context list AFTER the removal, so the owner can
* tell whether any other chip still references the removed chip's resource
* before closing a shared slideover tab.
*/
onContextRemove: (context: ChatContext, remaining: ChatContext[]) => void
/** Opens a workspace resource referenced from rendered message content. */
onWorkspaceResourceSelect: (resource: MothershipResource) => void
}
Expand All @@ -42,7 +47,7 @@ interface ChatSurfaceProviderProps {
chatId?: string
userId?: string
onContextAdd?: (context: ChatContext) => void
onContextRemove?: (context: ChatContext) => void
onContextRemove?: (context: ChatContext, remaining: ChatContext[]) => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
children: ReactNode
}
Expand Down Expand Up @@ -74,8 +79,8 @@ export function ChatSurfaceProvider({
const stableOnContextAdd = useCallback((context: ChatContext) => {
onContextAddRef.current?.(context)
}, [])
const stableOnContextRemove = useCallback((context: ChatContext) => {
onContextRemoveRef.current?.(context)
const stableOnContextRemove = useCallback((context: ChatContext, remaining: ChatContext[]) => {
onContextRemoveRef.current?.(context, remaining)
}, [])
const stableOnWorkspaceResourceSelect = useCallback((resource: MothershipResource) => {
onWorkspaceResourceSelectRef.current?.(resource)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,31 @@ export function serializeSelectionForClipboard(
return result
}

/**
* Finds the selection-scoped chips (`file_selection` / `table_selection`) whose
* highlighted token falls inside `selectedText` — the chips the copy/cut path
* must route through the custom clipboard MIME rather than a portable link.
*
* Uses the overlay's exact tokenization so a label that is a substring of
* another never false-matches.
*/
export function selectionContextsInText(
selectedText: string,
contexts: ChatContext[]
): ChatContext[] {
const ranges = computeMentionHighlightRanges(selectedText, extractContextTokens(contexts))
if (ranges.length === 0) return []
const found: ChatContext[] = []
for (const range of ranges) {
const label = stripMentionTrigger(range.token)
const matched = contexts.find((c) => c.label === label)
if (matched && (matched.kind === 'file_selection' || matched.kind === 'table_selection')) {
found.push(matched)
}
}
return found
}

/**
* Parses all portable chip markdown links from a string, in source order.
*
Expand Down
Loading
Loading