Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,928 @@
|
||||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
|
||||
import {
|
||||
type ClipboardEvent,
|
||||
type FC,
|
||||
type FocusEvent,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type DragEvent as ReactDragEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import { ComposerDirectiveActions } from '@/app/chat/composer/directive-actions'
|
||||
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from '@/app/chat/composer/drop-affordance'
|
||||
import {
|
||||
type ComposerInsertMode,
|
||||
focusComposerInput,
|
||||
markActiveComposer,
|
||||
onComposerFocusRequest,
|
||||
onComposerInsertRequest,
|
||||
releaseActiveComposer
|
||||
} from '@/app/chat/composer/focus'
|
||||
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
|
||||
import { rebuildAroundCaret, triggerKeyUpHandler } from '@/app/chat/composer/hooks/use-composer-trigger'
|
||||
import { useComposerUndo } from '@/app/chat/composer/hooks/use-composer-undo'
|
||||
import { useEmojiCompletions } from '@/app/chat/composer/hooks/use-emoji-completions'
|
||||
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
|
||||
import {
|
||||
dragHasAttachments,
|
||||
droppedFileInlineRefs,
|
||||
type InlineRefInput,
|
||||
insertInlineRefsIntoEditor
|
||||
} from '@/app/chat/composer/inline-refs'
|
||||
import { chipTypedPathOnSpace, pathifyRefs } from '@/app/chat/composer/path-refs'
|
||||
import {
|
||||
composerPlainText,
|
||||
insertComposerContentsAtCaret,
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
replaceBeforeCaret,
|
||||
RICH_INPUT_SLOT
|
||||
} from '@/app/chat/composer/rich-editor'
|
||||
import { detectTrigger, openDirectiveScope, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
|
||||
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
|
||||
import { isRedoShortcut, isUndoShortcut } from '@/app/chat/composer/undo-history'
|
||||
import { chipTypedUrlOnSpace, linkifyUrls } from '@/app/chat/composer/url-refs'
|
||||
import {
|
||||
extractDroppedFiles,
|
||||
HERMES_PATHS_MIME,
|
||||
isImagePath,
|
||||
partitionDroppedFiles
|
||||
} from '@/app/chat/hooks/use-composer-actions'
|
||||
import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions'
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import {
|
||||
StickyHumanMessageContainer,
|
||||
StopGlyph,
|
||||
USER_ACTION_ICON_BUTTON_CLASS,
|
||||
USER_ACTION_ICON_SIZE,
|
||||
USER_BUBBLE_BASE_CLASS
|
||||
} from '@/components/assistant-ui/thread/user-message'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime'
|
||||
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Loader2Icon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $terminalBackend } from '@/store/session'
|
||||
import { isSessionRemote } from '@/store/session-states'
|
||||
import { notifyThreadEditClose } from '@/store/thread-scroll'
|
||||
|
||||
interface UserEditComposerProps {
|
||||
cwd: string | null
|
||||
gateway: HermesGateway | null
|
||||
sessionId: string | null
|
||||
}
|
||||
|
||||
export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const aui = useAui()
|
||||
const draft = useAuiState(s => s.composer.text)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
// Capture the original draft immediately before the first edit. The runtime
|
||||
// may hydrate composer.text after this component's first render, so taking a
|
||||
// mount-time snapshot can incorrectly classify every later blur as dirty.
|
||||
const initialDraftRef = useRef<string | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const composingRef = useRef(false)
|
||||
const dragDepthRef = useRef(0)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
// See index.tsx: set in keydown when the open popover consumes a nav/control
|
||||
// key so the matching keyup skips refreshTrigger (timing-immune vs reading
|
||||
// `trigger`, which keyup sees as already-null after Escape).
|
||||
const triggerKeyConsumedRef = useRef(false)
|
||||
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
// True while OS-drop files are being staged/uploaded into the session. Blocks
|
||||
// submit and shows a spinner so confirming the edit can't race the async
|
||||
// upload and drop the gateway-side ref before it lands in the draft.
|
||||
const [staging, setStaging] = useState(false)
|
||||
const expanded = draft.includes('\n')
|
||||
const canSubmit = draft.trim().length > 0
|
||||
const at = useAtCompletions({ cwd, gateway, sessionId })
|
||||
const slash = useSlashCompletions({ gateway })
|
||||
const emoji = useEmojiCompletions()
|
||||
|
||||
// Timers this composer schedules must not outlive it. Every callback below
|
||||
// touches component state, a ref or the composer core, and this is the one
|
||||
// composer that routinely unmounts mid-flight: confirming an edit tears it
|
||||
// down while the 200ms submit latch is still pending, so the latch resumes
|
||||
// against an unmounted tree. Two of the callbacks already carry defensive
|
||||
// try/catch for the racing-teardown case; clearing the timers removes the
|
||||
// race instead of surviving it.
|
||||
const pendingTimeoutsRef = useRef<Set<number>>(new Set())
|
||||
|
||||
const scheduleTimeout = useCallback((run: () => void, delayMs: number): void => {
|
||||
const id = window.setTimeout(() => {
|
||||
pendingTimeoutsRef.current.delete(id)
|
||||
run()
|
||||
}, delayMs)
|
||||
|
||||
pendingTimeoutsRef.current.add(id)
|
||||
}, [])
|
||||
|
||||
// This is the one composer that routinely unmounts, so it is where the focus
|
||||
// bus leaks: confirming or cancelling an edit tears the composer down while
|
||||
// `'edit'` is still the active target. Release it alongside the thread-scroll
|
||||
// cleanup so keyboard routing falls back to the visible chat composer.
|
||||
//
|
||||
// It also drains whatever `scheduleTimeout` still has pending, which is a
|
||||
// second concern under the same heading rather than a separate one: both
|
||||
// are "this composer is going away", they unmount together by definition,
|
||||
// and a sibling unmount-only effect would only be a second place to forget.
|
||||
useEffect(
|
||||
() => () => {
|
||||
notifyThreadEditClose()
|
||||
releaseActiveComposer('edit')
|
||||
|
||||
for (const id of pendingTimeoutsRef.current) {
|
||||
window.clearTimeout(id)
|
||||
}
|
||||
|
||||
pendingTimeoutsRef.current.clear()
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const focusEditor = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
focusComposerInput(editor)
|
||||
|
||||
if (editor) {
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
markActiveComposer('edit')
|
||||
}, [])
|
||||
|
||||
const requestEditFocus = useCallback(() => {
|
||||
setFocusRequestId(id => id + 1)
|
||||
}, [])
|
||||
|
||||
const rememberInitialDraft = useCallback(() => {
|
||||
if (initialDraftRef.current === null) {
|
||||
initialDraftRef.current = draftRef.current
|
||||
}
|
||||
}, [])
|
||||
|
||||
const appendExternalText = useCallback(
|
||||
(text: string, mode: ComposerInsertMode) => {
|
||||
const value = text.trim()
|
||||
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
rememberInitialDraft()
|
||||
const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current
|
||||
const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : ''
|
||||
const next = `${base}${sep}${value}`
|
||||
|
||||
draftRef.current = next
|
||||
aui.composer().setText(next)
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
renderComposerContents(editor, next, { trailingCommitted: true })
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
setFocusRequestId(id => id + 1)
|
||||
},
|
||||
[aui, rememberInitialDraft]
|
||||
)
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
draftRef.current = draft
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
if (
|
||||
editor &&
|
||||
(editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft))
|
||||
) {
|
||||
// Inert by construction — this repaints on mount or when the editor
|
||||
// isn't the one being typed into. A message opened for edit is finished
|
||||
// text, so a `/command` ending it is committed and chips, matching how
|
||||
// the transcript rendered that same message a moment ago.
|
||||
renderComposerContents(editor, draft, { trailingCommitted: true })
|
||||
|
||||
if (document.activeElement === editor) {
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
}
|
||||
}, [draft])
|
||||
|
||||
useEffect(() => {
|
||||
focusEditor()
|
||||
}, [focusEditor, focusRequestId])
|
||||
|
||||
useEffect(() => {
|
||||
const offFocus = onComposerFocusRequest(({ target }) => {
|
||||
if (target === 'edit') {
|
||||
setFocusRequestId(id => id + 1)
|
||||
}
|
||||
})
|
||||
|
||||
const offInsert = onComposerInsertRequest(({ mode, target, text }) => {
|
||||
if (target === 'edit') {
|
||||
appendExternalText(text, mode)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
offFocus()
|
||||
offInsert()
|
||||
}
|
||||
}, [appendExternalText])
|
||||
|
||||
const syncDraftFromEditor = useCallback(
|
||||
(editor: HTMLDivElement) => {
|
||||
const nextDraft = sanitizeComposerInput(composerPlainText(editor))
|
||||
|
||||
if (nextDraft !== draftRef.current) {
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
}
|
||||
|
||||
return nextDraft
|
||||
},
|
||||
[aui]
|
||||
)
|
||||
|
||||
// Same stack the main composer owns, for the same reason: the editor mutates
|
||||
// through `Range` to dodge Chromium's O(n²) editing pipeline, which also
|
||||
// dodges its undo stack, so a paste was invisible to Cmd+Z. `rememberInitialDraft`
|
||||
// already marks every mutation site (it's the dirty-edit guard), so the undo
|
||||
// points ride along with it.
|
||||
const syncFromEditorRef = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
return editor ? syncDraftFromEditor(editor) : draftRef.current
|
||||
}, [syncDraftFromEditor])
|
||||
|
||||
const { recordUndoPoint, redo, undo, withUndoPoint } = useComposerUndo({
|
||||
editorRef,
|
||||
syncDraftFromEditor: syncFromEditorRef
|
||||
})
|
||||
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const before = textBeforeCaret(editor)
|
||||
const detected = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
||||
if (detected) {
|
||||
const rect = editor.getBoundingClientRect()
|
||||
const spaceAbove = rect.top
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
|
||||
setTriggerPlacement(spaceAbove < 220 && spaceBelow > spaceAbove ? 'bottom' : 'top')
|
||||
}
|
||||
|
||||
setTrigger(detected)
|
||||
|
||||
// Only reset the highlight when the trigger actually changed (opened, or
|
||||
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
|
||||
// caret move (mouseup) or a stray refresh — must preserve the user's
|
||||
// current selection instead of snapping back to the first item.
|
||||
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
|
||||
setTriggerActive(0)
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
const closeTrigger = useCallback(() => {
|
||||
setTrigger(null)
|
||||
setTriggerItems([])
|
||||
setTriggerActive(0)
|
||||
}, [])
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null =
|
||||
trigger?.kind === '@'
|
||||
? at.adapter
|
||||
: trigger?.kind === '/'
|
||||
? slash.adapter
|
||||
: trigger?.kind === ':'
|
||||
? emoji.adapter
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger || !triggerAdapter?.search) {
|
||||
setTriggerItems([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setTriggerItems(triggerAdapter.search(trigger.query))
|
||||
}, [trigger, triggerAdapter])
|
||||
|
||||
useEffect(() => {
|
||||
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
|
||||
}, [triggerItems.length])
|
||||
|
||||
const triggerLoading =
|
||||
trigger?.kind === '@'
|
||||
? at.loading
|
||||
: trigger?.kind === '/'
|
||||
? slash.loading
|
||||
: trigger?.kind === ':'
|
||||
? emoji.loading
|
||||
: false
|
||||
|
||||
const replaceTriggerWithChip = useCallback(
|
||||
(item: Unstable_TriggerItem) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor || !trigger) {
|
||||
return
|
||||
}
|
||||
|
||||
rememberInitialDraft()
|
||||
recordUndoPoint()
|
||||
const serialized = hermesDirectiveFormatter.serialize(item)
|
||||
const starter = serialized.endsWith(':')
|
||||
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
||||
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
|
||||
|
||||
const finish = () => {
|
||||
draftRef.current = composerPlainText(editor)
|
||||
aui.composer().setText(draftRef.current)
|
||||
requestEditFocus()
|
||||
starter ? scheduleTimeout(refreshTrigger, 0) : closeTrigger()
|
||||
}
|
||||
|
||||
// In place first, spanning Chromium's split text nodes (see
|
||||
// rangeBeforeCaret). The re-render fallback only runs when the caret
|
||||
// genuinely can't anchor the token — it rebuilds from serialized text,
|
||||
// which re-chips `@` refs but resets the caret to the end.
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
directive
|
||||
? fragment.append(refChipElement(directive[1], directive[2]), document.createTextNode(' '))
|
||||
: fragment.append(document.createTextNode(text))
|
||||
|
||||
if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) {
|
||||
rebuildAroundCaret(editor, trigger.tokenLength, text)
|
||||
}
|
||||
|
||||
finish()
|
||||
},
|
||||
[
|
||||
aui,
|
||||
closeTrigger,
|
||||
recordUndoPoint,
|
||||
refreshTrigger,
|
||||
rememberInitialDraft,
|
||||
requestEditFocus,
|
||||
scheduleTimeout,
|
||||
trigger
|
||||
]
|
||||
)
|
||||
|
||||
const insertRefStrings = useCallback(
|
||||
(refs: InlineRefInput[]) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor || refs.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Bank BEFORE the insert — insertInlineRefsIntoEditor mutates in place, so
|
||||
// recording after it would snapshot the state we're trying to undo to.
|
||||
const undone = withUndoPoint(() => insertInlineRefsIntoEditor(editor, refs) !== null)
|
||||
|
||||
if (!undone) {
|
||||
return false
|
||||
}
|
||||
|
||||
rememberInitialDraft()
|
||||
const nextDraft = composerPlainText(editor)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
requestEditFocus()
|
||||
|
||||
return true
|
||||
},
|
||||
[aui, rememberInitialDraft, requestEditFocus, withUndoPoint]
|
||||
)
|
||||
|
||||
const insertDroppedRefs = useCallback(
|
||||
(candidates: ReturnType<typeof extractDroppedFiles>) => insertRefStrings(droppedFileInlineRefs(candidates, cwd)),
|
||||
[cwd, insertRefStrings]
|
||||
)
|
||||
|
||||
// OS/Finder drops carry an absolute path on THIS machine — the gateway can't
|
||||
// read it in remote mode, and an image needs its bytes uploaded for vision.
|
||||
// Stage each through the same file.attach/image.attach_bytes pipeline the main
|
||||
// composer uses, then insert the *gateway-side* ref the agent can resolve —
|
||||
// never the raw local path (the MahmoudR remote-attach bug, which the main
|
||||
// composer fixes but this edit composer used to reproduce).
|
||||
const uploadOsDropRefs = useCallback(
|
||||
async (osDrops: ReturnType<typeof extractDroppedFiles>): Promise<InlineRefInput[]> => {
|
||||
if (!gateway || !sessionId) {
|
||||
// No session to stage into — best-effort inline refs (matches old path).
|
||||
return droppedFileInlineRefs(osDrops, cwd)
|
||||
}
|
||||
|
||||
const remote = isSessionRemote(sessionId)
|
||||
|
||||
const requestGateway = <T,>(method: string, params?: Record<string, unknown>) =>
|
||||
gateway.request<T>(method, params)
|
||||
|
||||
const refs: InlineRefInput[] = []
|
||||
|
||||
for (const candidate of osDrops) {
|
||||
const path = candidate.path || ''
|
||||
|
||||
if (!path) {
|
||||
continue
|
||||
}
|
||||
|
||||
const kind: ComposerAttachment['kind'] =
|
||||
candidate.file?.type.startsWith('image/') || isImagePath(candidate.file?.name || path) ? 'image' : 'file'
|
||||
|
||||
try {
|
||||
const uploaded = await uploadComposerAttachment(
|
||||
{ detail: path, id: attachmentId(kind, path), kind, label: pathLabel(path), path },
|
||||
{ backendCwd: cwd, remote, requestGateway, sessionId, terminalBackend: $terminalBackend.get() }
|
||||
)
|
||||
|
||||
const ref = attachmentDisplayText(uploaded)
|
||||
|
||||
if (ref) {
|
||||
refs.push(ref)
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, t.desktop.dropFiles)
|
||||
}
|
||||
}
|
||||
|
||||
return refs
|
||||
},
|
||||
[cwd, gateway, sessionId, t.desktop.dropFiles]
|
||||
)
|
||||
|
||||
const resetDragState = useCallback(() => {
|
||||
dragDepthRef.current = 0
|
||||
setDragActive(false)
|
||||
}, [])
|
||||
|
||||
const handleDragEnter = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
dragDepthRef.current += 1
|
||||
|
||||
if (!dragActive) {
|
||||
setDragActive(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: ReactDragEvent<HTMLElement>) => {
|
||||
event.preventDefault()
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
|
||||
if (dragDepthRef.current === 0) {
|
||||
setDragActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidates = extractDroppedFiles(event.dataTransfer)
|
||||
|
||||
if (!candidates.length) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resetDragState()
|
||||
|
||||
// In-app drags (project tree / gutter) are workspace-relative paths that
|
||||
// resolve on the gateway as-is, so they stay inline refs. OS drops need to
|
||||
// be staged + uploaded first, then their gateway-side ref is inserted.
|
||||
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
|
||||
|
||||
if (insertDroppedRefs(inAppRefs)) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
|
||||
if (osDrops.length) {
|
||||
setStaging(true)
|
||||
void uploadOsDropRefs(osDrops)
|
||||
.then(refs => {
|
||||
if (insertRefStrings(refs)) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
})
|
||||
.finally(() => setStaging(false))
|
||||
}
|
||||
}
|
||||
|
||||
const flushEditorToDraft = useCallback(
|
||||
(editor: HTMLDivElement) => {
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
editor.replaceChildren()
|
||||
}
|
||||
|
||||
rememberInitialDraft()
|
||||
const nextDraft = syncDraftFromEditor(editor)
|
||||
scheduleTimeout(refreshTrigger, 0)
|
||||
|
||||
return nextDraft
|
||||
},
|
||||
[refreshTrigger, rememberInitialDraft, scheduleTimeout, syncDraftFromEditor]
|
||||
)
|
||||
|
||||
const handleInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
// Native typing/deleting still goes through Chromium's editing pipeline, whose
|
||||
// undo stack we've taken over — bank the pre-edit state here, while
|
||||
// `beforeinput` can still see the old text.
|
||||
const handleBeforeInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
const inputType = (event.nativeEvent as InputEvent).inputType
|
||||
|
||||
if (inputType === 'historyUndo' || inputType === 'historyRedo') {
|
||||
return
|
||||
}
|
||||
|
||||
recordUndoPoint({ coalesce: inputType === 'insertText' || inputType === 'deleteContentBackward' })
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const pastedText = sanitizeComposerInput(event.clipboardData.getData('text'))
|
||||
|
||||
if (!pastedText || DATA_IMAGE_URL_RE.test(pastedText.trim())) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
rememberInitialDraft()
|
||||
recordUndoPoint()
|
||||
|
||||
// Links land as `@url:` chips, same as the main composer — including
|
||||
// consuming an open `@url:` scope rather than stacking a second directive
|
||||
// in front of the chip.
|
||||
insertComposerContentsAtCaret(
|
||||
event.currentTarget,
|
||||
pathifyRefs(linkifyUrls(pastedText)),
|
||||
openDirectiveScope(event.currentTarget)
|
||||
)
|
||||
syncDraftFromEditor(event.currentTarget)
|
||||
}
|
||||
|
||||
const submitEdit = (editor: HTMLDivElement) => {
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextDraft = syncDraftFromEditor(editor)
|
||||
|
||||
if (submitting || staging || !nextDraft.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
// `aui.composer().send()` throws "Composer is not available" when the edit
|
||||
// composer core has been torn down (e.g. a blur-driven cancel raced the
|
||||
// click). Reset `submitting` on failure so the arrow can't wedge on `true`
|
||||
// and leave revert as the only way out (#49903 is the same unguarded-core
|
||||
// hazard on the main composer).
|
||||
try {
|
||||
aui.composer().send()
|
||||
|
||||
// Clear latch after cooldown to allow re-submission. This prevents rapid
|
||||
// double-Enter but doesn't require tracking when onEdit settles (which may
|
||||
// be synchronous or async, and whose promise we don't have access to).
|
||||
scheduleTimeout(() => {
|
||||
setSubmitting(false)
|
||||
}, 200)
|
||||
} catch {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditBlur = useCallback(
|
||||
(event: FocusEvent<HTMLDivElement>) => {
|
||||
const nextTarget = event.relatedTarget
|
||||
|
||||
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
|
||||
return
|
||||
}
|
||||
|
||||
scheduleTimeout(() => {
|
||||
const root = rootRef.current
|
||||
const active = document.activeElement
|
||||
|
||||
if (submitting || (root && active && root.contains(active))) {
|
||||
return
|
||||
}
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
// Dirty edit guard: when the user actually typed something, blur must
|
||||
// not cancel the composer — that would discard their in-flight
|
||||
// edits. Compare against the draft captured immediately before the
|
||||
// first edit; when no edit event occurred, the current hydrated draft
|
||||
// is the clean baseline.
|
||||
const initialDraft = initialDraftRef.current ?? draftRef.current
|
||||
|
||||
if (editor && syncDraftFromEditor(editor) !== initialDraft) {
|
||||
closeTrigger()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
closeTrigger()
|
||||
|
||||
// Swallow the unbound-core throw: if the composer core was already torn
|
||||
// down (a send/cancel raced this timer), cancel() throws "Composer is
|
||||
// not available" as an uncaught renderer error. Nothing to cancel then.
|
||||
try {
|
||||
aui.composer().cancel()
|
||||
} catch {
|
||||
// Composer core already gone — the edit is closing anyway.
|
||||
}
|
||||
}, 80)
|
||||
},
|
||||
[aui, closeTrigger, scheduleTimeout, submitting, syncDraftFromEditor]
|
||||
)
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
// Self-heal a stale composition flag (same recovery as the main composer's
|
||||
// handleEditorKeyDown, #44135): compositionend can be missed, and a wedged
|
||||
// composingRef would swallow every Enter until the edit composer remounts.
|
||||
if (composingRef.current && !event.nativeEvent.isComposing) {
|
||||
composingRef.current = false
|
||||
}
|
||||
|
||||
// IME composition: Enter confirms composed text, not a message submission.
|
||||
if (composingRef.current || event.nativeEvent.isComposing) {
|
||||
return
|
||||
}
|
||||
|
||||
// IME commit Enter still carrying keyCode 229 (VK_PROCESSKEY) after
|
||||
// compositionend — same guard as the main composer.
|
||||
if (event.key === 'Enter' && event.keyCode === 229) {
|
||||
return
|
||||
}
|
||||
|
||||
if (trigger && triggerItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx + 1) % triggerItems.length)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
const item = triggerItems[triggerActive]
|
||||
|
||||
if (item) {
|
||||
replaceTriggerWithChip(item)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
closeTrigger()
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Undo/redo before Escape — we own the stack, and a stray Cmd+Z must never
|
||||
// fall through to something that cancels the edit outright.
|
||||
if (isUndoShortcut(event.nativeEvent)) {
|
||||
event.preventDefault()
|
||||
undo()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (isRedoShortcut(event.nativeEvent)) {
|
||||
event.preventDefault()
|
||||
redo()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
aui.composer().cancel()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// A typed link finished with a space chips like a pasted one.
|
||||
if (withUndoPoint(() => chipTypedUrlOnSpace(event))) {
|
||||
event.preventDefault()
|
||||
rememberInitialDraft()
|
||||
syncDraftFromEditor(event.currentTarget)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Same for a bare `@path`.
|
||||
if (withUndoPoint(() => chipTypedPathOnSpace(event))) {
|
||||
event.preventDefault()
|
||||
rememberInitialDraft()
|
||||
syncDraftFromEditor(event.currentTarget)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
submitEdit(event.currentTarget)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = triggerKeyUpHandler(triggerKeyConsumedRef, refreshTrigger)
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root className="contents" data-slot="aui_edit-composer-root">
|
||||
<StickyHumanMessageContainer>
|
||||
<div
|
||||
className="composer-human-message-container human-execution-message-top relative flex w-full items-start rounded-md bg-(--ui-chat-surface-background)"
|
||||
// A raised box over the transcript field: under window glass it keeps
|
||||
// a near-opaque fill instead of thinning with the field behind it.
|
||||
data-glass-raised=""
|
||||
onBlur={handleEditBlur}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
ref={rootRef}
|
||||
>
|
||||
{trigger && (
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={triggerActive}
|
||||
items={triggerItems}
|
||||
kind={trigger.kind}
|
||||
loading={triggerLoading}
|
||||
onHover={setTriggerActive}
|
||||
onPick={replaceTriggerWithChip}
|
||||
placement={triggerPlacement}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
USER_BUBBLE_BASE_CLASS,
|
||||
'ui-prompt-input__container relative border-(--ui-stroke-secondary) data-[expanded=true]:min-h-20',
|
||||
COMPOSER_DROP_FADE_CLASS,
|
||||
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
||||
)}
|
||||
data-expanded={expanded ? 'true' : undefined}
|
||||
>
|
||||
<div
|
||||
aria-label={copy.editMessage}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
'ui-prompt-input-editor__input max-h-48 w-full resize-none overflow-y-auto bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] text-foreground/95 outline-none',
|
||||
'**:data-ref-text:cursor-default',
|
||||
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
|
||||
)}
|
||||
contentEditable
|
||||
data-placeholder={copy.editMessage}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBeforeInput={handleBeforeInput}
|
||||
onBlur={() => scheduleTimeout(closeTrigger, 80)}
|
||||
onCompositionEnd={event => {
|
||||
composingRef.current = false
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onFocus={() => markActiveComposer('edit')}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onMouseUp={refreshTrigger}
|
||||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
spellCheck={false}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
<ComposerDirectiveActions editorRef={editorRef} />
|
||||
<ComposerPrimitive.Input
|
||||
asChild
|
||||
className="sr-only"
|
||||
submitMode="ctrlEnter"
|
||||
tabIndex={-1}
|
||||
unstable_focusOnScrollToBottom={false}
|
||||
>
|
||||
<textarea
|
||||
aria-hidden
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="sr-only"
|
||||
spellCheck={false}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</ComposerPrimitive.Input>
|
||||
{staging && (
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-2 left-2 inline-flex items-center gap-1 rounded-full bg-background/80 px-1.5 py-0.5 text-[0.62rem] text-muted-foreground backdrop-blur-[1px]"
|
||||
data-slot="aui_edit-staging"
|
||||
>
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
{copy.attachingFile}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
aria-label={copy.sendEdited}
|
||||
className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
disabled={!canSubmit || submitting || staging}
|
||||
onClick={() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
submitEdit(editor)
|
||||
}
|
||||
}}
|
||||
// Keep focus in the editor on click: macOS doesn't focus a button
|
||||
// on mousedown, so without this the arrow-click blurs the editor,
|
||||
// the blur timer cancels the edit (tearing down the composer
|
||||
// core), and the click's send() then throws against a dead core —
|
||||
// the edit silently never sends. The restore button guards the
|
||||
// same way.
|
||||
onPointerDown={event => event.preventDefault()}
|
||||
title={copy.sendEdited}
|
||||
type="button"
|
||||
>
|
||||
{submitting ? StopGlyph : <Codicon name="arrow-up" size={USER_ACTION_ICON_SIZE} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</StickyHumanMessageContainer>
|
||||
</ComposerPrimitive.Root>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user