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
@@ -1,6 +1,6 @@
'use client'

import { memo, useEffect, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { cn, toast } from '@sim/emcn'
import type { JSONContent } from '@tiptap/core'
import { Fragment, Slice } from '@tiptap/pm/model'
Expand All @@ -9,13 +9,21 @@ import { dropPoint } from '@tiptap/pm/transform'
import type { Editor } from '@tiptap/react'
import { EditorContent, useEditor } from '@tiptap/react'
import { useRouter } from 'next/navigation'
import {
buildFileSelectionLabel,
selectionKey,
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 { 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 { createMarkdownEditorExtensions } from './editor-extensions'
import { findHeadingPos } from './heading-anchors'
import {
Expand Down Expand Up @@ -557,12 +565,46 @@ export function LoadedRichMarkdownEditor({
[]
)

const addToChat = useAddToChat()
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
// Markdown has no native line numbers; approximate from newlines before the
// selection so repeated selections in the same file get distinct labels.
const startLine = editor.state.doc.textBetween(0, from, '\n').split('\n').length
const endLine = startLine + text.split('\n').length - 1
return {
kind: 'file_selection',
fileId: file.id,
label: buildFileSelectionLabel(file.name, startLine, endLine, selectionKey([text])),
text: truncateSelectionText(text),
startLine,
endLine,
}
}, [editor, file.id, file.name])

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

useSelectionCopyBridge(containerRef, buildSelectionContext)

return (
<div
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,21 @@ 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,
selectionKey,
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 +381,31 @@ 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,
label: buildFileSelectionLabel(file.name, startLine, endLine, selectionKey([text])),
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,36 @@
'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.
*
* The listener is attached to `containerRef` in the BUBBLE phase so it runs
* AFTER the inner editor's own copy handler (Monaco and ProseMirror both call
* `clearData()` then write `text/plain`/`text/html`) — the custom
* `text/x-sim-selection` type is added last and survives, leaving normal copy
* untouched. `buildContext` returns `null` when there is no non-empty selection.
*
* `enabled` lets a caller whose container mounts late (e.g. behind a loading
* gate) re-run the effect once the node exists — a ref object isn't reactive, so
* without it the effect would 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 @@ -12,6 +12,7 @@ import {
} from '@sim/emcn/icons'
import { AgentSkillsIcon, McpIcon } from '@/components/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { fileNameFromSelectionLabel } from '@/lib/copilot/chat/selection-context'
import type { ChatContextKind, ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
import { getBareIconStyle } from '@/blocks/icon-color'
import { getBlockRegistry } from '@/blocks/registry'
Expand Down Expand Up @@ -75,13 +76,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 }) => {
// Strip the `:line` suffix so `getDocumentIcon` reads the real extension
// (e.g. `md`, not `md:12-40`) and shows the correct file glyph.
const FileDocIcon = getDocumentIcon('', fileNameFromSelectionLabel(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`. These kinds carry an inline
* text blob / row-id array that can't fit a portable `sim:kind/id` link, so the
* chat input's copy/cut path round-trips them through the custom
* `text/x-sim-selection` clipboard MIME instead. 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