(null)
@@ -144,7 +152,7 @@ export function EmptySession() {
const isMobileComposer = useMobileViewport() && !isDesktopRuntime()
useEffect(() => {
- textareaRef.current?.focus()
+ composerRef.current?.focus()
}, [])
useDismissable({
@@ -155,19 +163,19 @@ export function EmptySession() {
useDismissable({
open: slashMenuOpen,
- refs: [slashMenuRef, textareaRef],
+ refs: [slashMenuRef, composerContainerRef],
onDismiss: () => setSlashMenuOpen(false),
})
useDismissable({
open: !!localSlashPanel,
- refs: [slashMenuRef, textareaRef],
+ refs: [slashMenuRef, composerContainerRef],
onDismiss: () => setLocalSlashPanel(null),
})
useDismissable({
open: fileSearchOpen,
- refs: [textareaRef],
+ refs: [composerContainerRef],
onDismiss: () => setFileSearchOpen(false),
// See ChatInput: this menu is found by id, and its absence used to mean
// "ignore the press".
@@ -281,6 +289,7 @@ export function EmptySession() {
if (slashUiAction?.type === 'panel') {
setLocalSlashPanel(slashUiAction.command as LocalSlashCommandName)
setInput('')
+ setMentions([])
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
@@ -291,6 +300,7 @@ export function EmptySession() {
useUIStore.getState().setPendingSettingsTab(slashUiAction.tab)
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
setInput('')
+ setMentions([])
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
@@ -300,6 +310,7 @@ export function EmptySession() {
if (slashUiAction?.type === 'model') {
modelSelectorRef.current?.open()
setInput('')
+ setMentions([])
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
@@ -351,10 +362,14 @@ export function EmptySession() {
data: attachment.data,
mimeType: attachment.mimeType,
}))
- if (text || attachmentPayload.length > 0) {
- sendMessage(sessionId, text, attachmentPayload)
+ // Inline @-mentions go out as the `@"absolute path"` text the CLI parses,
+ // serialized from the live document; the bubble keeps the pill text.
+ const serializedText = (composerRef.current?.getModelContent() ?? input).trim()
+ if (serializedText || attachmentPayload.length > 0) {
+ sendMessage(sessionId, serializedText, attachmentPayload, { displayContent: text })
}
setInput('')
+ setMentions([])
setAttachments([])
} catch (error) {
addToast({
@@ -366,8 +381,10 @@ export function EmptySession() {
}
}
- const handleInputChange = (value: string, cursorPos: number) => {
+ const handleComposerChange = (value: string, nextMentions: ComposerMention[]) => {
setInput(value)
+ setMentions(nextMentions)
+ const cursorPos = composerRef.current?.getSelectionOffsets().start ?? value.length
const token = findSlashToken(value, cursorPos)
if (!token) {
setSlashMenuOpen(false)
@@ -376,7 +393,8 @@ export function EmptySession() {
setSlashMenuOpen(true)
}
- // Detect @ trigger for file search
+ // Detect @ trigger for file search, skipping an `@` that belongs to an
+ // existing mention pill.
const textBeforeCursor = value.slice(0, cursorPos)
let pos = -1
for (let i = textBeforeCursor.length - 1; i >= 0; i--) {
@@ -392,6 +410,9 @@ export function EmptySession() {
break
}
}
+ if (pos >= 0 && findMentionRanges(value, nextMentions).some((range) => pos >= range.start && pos < range.end)) {
+ pos = -1
+ }
if (pos < 0) {
setFileSearchOpen(false)
setAtFilter('')
@@ -404,9 +425,9 @@ export function EmptySession() {
}
}
- const handleKeyDown = (event: React.KeyboardEvent) => {
+ const handleComposerKeyDown = (event: KeyboardEvent): boolean => {
// Ignore key events during IME composition (e.g. Chinese input method)
- if (event.nativeEvent.isComposing) return
+ if (event.isComposing || event.keyCode === 229) return false
// Route file search navigation keys to FileSearchMenu
if (fileSearchOpen) {
@@ -417,24 +438,24 @@ export function EmptySession() {
setFileSearchOpen(false)
setAtFilter('')
setAtCursorPos(-1)
- return
+ return true
}
- fileSearchRef.current?.handleKeyDown(event.nativeEvent)
- return
+ fileSearchRef.current?.handleKeyDown(event)
+ return true
}
- return
+ return false
}
if (slashMenuOpen && filteredCommands.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
setSlashSelectedIndex((prev) => (prev + 1) % filteredCommands.length)
- return
+ return true
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setSlashSelectedIndex((prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length)
- return
+ return true
}
if (event.key === 'Enter' || event.key === 'Tab') {
const selected = filteredCommands[slashSelectedIndex]
@@ -447,28 +468,30 @@ export function EmptySession() {
) {
event.preventDefault()
void handleSubmit()
- return
+ return true
}
event.preventDefault()
if (selected) selectSlashCommand(selected.name)
- return
+ return true
}
if (event.key === 'Escape') {
event.preventDefault()
setSlashMenuOpen(false)
- return
+ return true
}
}
if (shouldSubmitOnEnter(event, chatSendBehavior)) {
event.preventDefault()
- handleSubmit()
+ void handleSubmit()
+ return true
}
+ return false
}
- const handlePaste = (event: React.ClipboardEvent) => {
- const files = getDataTransferFiles(event.clipboardData)
- if (files.length === 0) return
+ const handleComposerPaste = (event: ClipboardEvent): boolean => {
+ const files = event.clipboardData ? getDataTransferFiles(event.clipboardData) : []
+ if (files.length === 0) return false
event.preventDefault()
void filesToComposerAttachments(files)
@@ -479,6 +502,7 @@ export function EmptySession() {
.catch((error) => {
console.warn('[attachments] Failed to read pasted files', error)
})
+ return true
}
const appendFiles = useCallback((files: FileList | File[]) => {
@@ -537,30 +561,27 @@ export function EmptySession() {
}
const selectSlashCommand = (command: string) => {
- const el = textareaRef.current
- if (!el) return
- const cursorPos = el.selectionStart ?? input.length
+ const cursorPos = composerRef.current?.getSelectionOffsets().start ?? input.length
const replacement = replaceSlashCommand(input, cursorPos, command)
if (!replacement) return
setInput(replacement.value)
setSlashMenuOpen(false)
requestAnimationFrame(() => {
- el.focus()
- el.setSelectionRange(replacement.cursorPos, replacement.cursorPos)
+ composerRef.current?.focus()
+ composerRef.current?.setSelectionOffsets(replacement.cursorPos)
})
}
const insertSlashCommand = () => {
- const el = textareaRef.current
- const cursorPos = el?.selectionStart ?? input.length
+ const cursorPos = composerRef.current?.getSelectionOffsets().start ?? input.length
const replacement = insertSlashTrigger(input, cursorPos)
setInput(replacement.value)
setPlusMenuOpen(false)
setSlashFilter('')
setSlashMenuOpen(true)
requestAnimationFrame(() => {
- textareaRef.current?.focus()
- textareaRef.current?.setSelectionRange(replacement.cursorPos, replacement.cursorPos)
+ composerRef.current?.focus()
+ composerRef.current?.setSelectionOffsets(replacement.cursorPos)
})
}
@@ -632,37 +653,28 @@ export function EmptySession() {
setInput(newValue)
setAtFilter(relativePath)
requestAnimationFrame(() => {
- textareaRef.current?.focus()
- textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
+ composerRef.current?.focus()
+ composerRef.current?.setSelectionOffsets(newCursorPos)
})
}}
- onSelect={(path, name) => {
- if (atCursorPos >= 0) {
- const attachmentName = name.split('/').filter(Boolean).pop() ?? name
- const tokenEnd = atCursorPos + 1 + atFilter.length
- const beforeToken = input.slice(0, atCursorPos)
- const afterToken = beforeToken ? input.slice(tokenEnd) : input.slice(tokenEnd).replace(/^\s+/, '')
- const spacer = beforeToken && afterToken && !/\s$/.test(beforeToken) && !/^\s/.test(afterToken) ? ' ' : ''
- const newValue = `${beforeToken}${spacer}${afterToken}`
- const newCursorPos = atCursorPos + spacer.length
- setAttachments((prev) => [
- ...prev,
- {
- id: `att-${Date.now()}-${Math.random().toString(36).slice(2)}`,
- name: attachmentName,
- type: 'file',
- path,
- },
- ])
- setInput(newValue)
- setFileSearchOpen(false)
- setAtFilter('')
- setAtCursorPos(-1)
- void textareaRef.current?.focus()
- requestAnimationFrame(() => {
- textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
- })
- }
+ onSelect={(path, name, isDirectory) => {
+ if (atCursorPos < 0) return
+ const referenceName = name.split('/').filter(Boolean).pop() ?? name
+ const tokenEnd = atCursorPos + 1 + atFilter.length
+ const inserted = insertMentionIntoText(input, mentions, atCursorPos, tokenEnd, {
+ label: isDirectory ? `${referenceName}/` : referenceName,
+ path,
+ isDirectory,
+ })
+ setInput(inserted.text)
+ setMentions(inserted.mentions)
+ setFileSearchOpen(false)
+ setAtFilter('')
+ setAtCursorPos(-1)
+ void composerRef.current?.focus()
+ requestAnimationFrame(() => {
+ composerRef.current?.setSelectionOffsets(inserted.cursorPos)
+ })
}}
/>
)}
@@ -696,25 +708,28 @@ export function EmptySession() {
)}
-
diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts
index 02b2cc2c..96d32125 100644
--- a/desktop/src/stores/chatStore.ts
+++ b/desktop/src/stores/chatStore.ts
@@ -17,6 +17,7 @@ import {
import { hasRunningBackgroundTasks, hasRunningSubagentTasks } from '../lib/backgroundTasks'
import { AGENT_LIFECYCLE_TYPES } from '../types/team'
import type { ComposerAttachment } from '../lib/composerAttachments'
+import type { ComposerMention } from '../lib/composerMentions'
import type { MessageEntry } from '../types/session'
import type { PermissionMode } from '../types/settings'
import type { RuntimeSelection } from '../types/runtime'
@@ -52,6 +53,8 @@ type CompactSummaryMessage = Extract
export type ComposerDraftState = {
input: string
attachments: ComposerAttachment[]
+ /** Inline @-mention pills in the draft, in document order. */
+ mentions?: ComposerMention[]
}
export type QueuedUserMessage = {
diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css
index ddf2d6ce..bedf15b6 100644
--- a/desktop/src/theme/globals.css
+++ b/desktop/src/theme/globals.css
@@ -1502,6 +1502,55 @@ button, input, textarea, select, a, [role="button"] {
box-shadow: var(--shadow-focus-ring), var(--shadow-dropdown);
}
+/* ProseMirror composer (MentionComposer). Paragraphs carry the line breaks,
+ so only space preservation needs `pre-wrap`. */
+.composer-pm {
+ outline: none;
+ font-family: var(--font-body);
+}
+
+.composer-pm p {
+ margin: 0;
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+}
+
+/* Floated zero-height placeholder: the empty paragraph keeps its own caret,
+ the hint text just borrows its line. */
+.composer-pm[data-empty="true"]::before {
+ content: attr(data-placeholder);
+ color: var(--color-text-tertiary);
+ float: left;
+ height: 0;
+ pointer-events: none;
+}
+
+/* Inline @-mention pill (an atom node in the document). */
+.composer-mention {
+ color: var(--color-success);
+ background: var(--color-success-container);
+ border-radius: var(--radius-md);
+ padding: 0 4px;
+ margin: 0 1px;
+ overflow-wrap: anywhere;
+}
+
+.composer-mention-icon {
+ font-size: 15px;
+ vertical-align: -3px;
+ margin-right: 1px;
+}
+
+/* The editor reports `contenteditable="false"` when the workspace is gone;
+ keep the old textarea's dimmed disabled look. */
+.composer-pm[contenteditable="false"] {
+ opacity: 0.5;
+}
+
+.composer-pm .ProseMirror-selectednode.composer-mention {
+ outline: 2px solid var(--color-border-focus);
+}
+
.composer-drop-overlay {
animation: composer-drop-fade 140ms ease-out, composer-drop-pulse 1.1s ease-in-out infinite;
}