|
@@ -24,6 +24,7 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
import type { ComposerBarProps } from '../contract/slots.ts'
|
|
import type { ComposerBarProps } from '../contract/slots.ts'
|
|
|
import { deriveDecorations } from '../input/decorations.ts'
|
|
import { deriveDecorations } from '../input/decorations.ts'
|
|
|
import type { DraftDecorations } from '../input/decorations.ts'
|
|
import type { DraftDecorations } from '../input/decorations.ts'
|
|
|
|
|
+import type { EditRange } from '../input/contract.ts'
|
|
|
import { attachmentErrorText, imageSizeText } from '../image-labels.ts'
|
|
import { attachmentErrorText, imageSizeText } from '../image-labels.ts'
|
|
|
import { ReferenceIcon } from '../reference/ReferenceIcon.tsx'
|
|
import { ReferenceIcon } from '../reference/ReferenceIcon.tsx'
|
|
|
import { ContextMeter } from './ContextMeter.tsx'
|
|
import { ContextMeter } from './ContextMeter.tsx'
|
|
@@ -34,6 +35,45 @@ import css from './InputBar.module.css'
|
|
|
/** Decoration product of the no-session state (no machine, empty draft). */
|
|
/** Decoration product of the no-session state (no machine, empty draft). */
|
|
|
const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null }
|
|
const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null }
|
|
|
|
|
|
|
|
|
|
+/** The selection and edit family a `beforeinput` recorded, with the draft length it applied to. */
|
|
|
|
|
+interface PendingEdit {
|
|
|
|
|
+ readonly start: number
|
|
|
|
|
+ readonly end: number
|
|
|
|
|
+ readonly draftLength: number
|
|
|
|
|
+ readonly inputType: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Resolve one edit's range from the record taken before it applied.
|
|
|
|
|
+ * A selection the edit replaces is the range outright. A caret delete replaces
|
|
|
|
|
+ * nothing and reports the bare caret, so the removed span is whatever the draft
|
|
|
|
|
+ * lost, on the side `inputType` names — measured, because one caret gesture can
|
|
|
|
|
+ * remove a multi-unit grapheme, a word, or a line.
|
|
|
|
|
+ * @param pending - record taken at `beforeinput`, null when none was seen.
|
|
|
|
|
+ * @param prevLength - length of the draft the edit applied to.
|
|
|
|
|
+ * @param nextLength - length of the resulting draft.
|
|
|
|
|
+ * @returns the exact range, or undefined when the record cannot describe this
|
|
|
|
|
+ * edit and the machine's diff scan has to recover it.
|
|
|
|
|
+ */
|
|
|
|
|
+function editRangeOf(pending: PendingEdit | null, prevLength: number, nextLength: number): EditRange | undefined {
|
|
|
|
|
+ if (pending === null || pending.draftLength !== prevLength) return undefined
|
|
|
|
|
+ const { start, end, inputType } = pending
|
|
|
|
|
+ // A DOM selection cannot invert; the check keeps that a precondition of the
|
|
|
|
|
+ // math below rather than an assumption about the element.
|
|
|
|
|
+ if (start > end || end > prevLength) return undefined
|
|
|
|
|
+ const insertedLength = nextLength - prevLength + (end - start)
|
|
|
|
|
+ if (insertedLength >= 0) return { start, end, insertedLength }
|
|
|
|
|
+ if (start !== end) return undefined
|
|
|
|
|
+ const removed = prevLength - nextLength
|
|
|
|
|
+ if (inputType.endsWith('Backward')) {
|
|
|
|
|
+ return removed <= start ? { start: start - removed, end: start, insertedLength: 0 } : undefined
|
|
|
|
|
+ }
|
|
|
|
|
+ if (inputType.endsWith('Forward')) {
|
|
|
|
|
+ return start + removed <= prevLength ? { start, end: start + removed, insertedLength: 0 } : undefined
|
|
|
|
|
+ }
|
|
|
|
|
+ return undefined
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export type InputBarProps = ComposerBarProps
|
|
export type InputBarProps = ComposerBarProps
|
|
|
|
|
|
|
|
export function InputBar({
|
|
export function InputBar({
|
|
@@ -277,6 +317,34 @@ export function InputBar({
|
|
|
})
|
|
})
|
|
|
/* oxlint-enable typescript/no-unnecessary-condition */
|
|
/* oxlint-enable typescript/no-unnecessary-condition */
|
|
|
|
|
|
|
|
|
|
+ // The machine's occurrence math needs the edit's real range, and a controlled
|
|
|
|
|
+ // textarea's change event carries only the resulting string. `beforeinput`
|
|
|
|
|
+ // fires while the element still holds the pre-edit selection, which is
|
|
|
|
|
+ // exactly the range about to be replaced; a textarea exposes it no other way
|
|
|
|
|
+ // (`getTargetRanges()` is empty for form controls). Recovering the range by
|
|
|
|
|
+ // diffing the two drafts instead is ambiguous whenever the typed text repeats
|
|
|
|
|
+ // what it lands against — typing the trigger char before a reference reads as
|
|
|
|
|
+ // landing inside that reference, which drops it. One lifetime, like the wheel
|
|
|
|
|
+ // listener above: the textarea is never unmounted.
|
|
|
|
|
+ const pendingEditRef = useRef<PendingEdit | null>(null)
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ const el = inputRef.current
|
|
|
|
|
+ if (el === null) return
|
|
|
|
|
+ const onBeforeInput = (e: InputEvent): void => {
|
|
|
|
|
+ // Only the families whose reported selection describes the edit. A
|
|
|
|
|
+ // history replay reports wherever the caret happens to sit, which would
|
|
|
|
|
+ // survive every check in editRangeOf while naming the wrong span.
|
|
|
|
|
+ if (!e.inputType.startsWith('insert') && !e.inputType.startsWith('delete')) {
|
|
|
|
|
+ pendingEditRef.current = null
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const { start, end } = selectionOf(el)
|
|
|
|
|
+ pendingEditRef.current = { start, end, draftLength: el.value.length, inputType: e.inputType }
|
|
|
|
|
+ }
|
|
|
|
|
+ el.addEventListener('beforeinput', onBeforeInput)
|
|
|
|
|
+ return () => { el.removeEventListener('beforeinput', onBeforeInput) }
|
|
|
|
|
+ }, [])
|
|
|
|
|
+
|
|
|
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
|
|
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
|
|
|
if (workspaceTrigger) {
|
|
if (workspaceTrigger) {
|
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
@@ -371,8 +439,10 @@ export function InputBar({
|
|
|
if (keyboard === undefined || locked) return // disabled/read-only states cannot edit the draft
|
|
if (keyboard === undefined || locked) return // disabled/read-only states cannot edit the draft
|
|
|
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
|
|
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
|
|
|
const next = e.target.value
|
|
const next = e.target.value
|
|
|
|
|
+ const pending = pendingEditRef.current
|
|
|
|
|
+ pendingEditRef.current = null
|
|
|
safariNativeShrinkRef.current = safari && next.length < draft.length
|
|
safariNativeShrinkRef.current = safari && next.length < draft.length
|
|
|
- keyboard.setDraft(next)
|
|
|
|
|
|
|
+ keyboard.setDraft(next, editRangeOf(pending, draft.length, next.length))
|
|
|
// selectionStart is number|null in lib.dom; the type-aware lint program narrows it.
|
|
// selectionStart is number|null in lib.dom; the type-aware lint program narrows it.
|
|
|
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
|
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
|
|
keyboard.track(next, e.target.selectionStart ?? next.length)
|
|
keyboard.track(next, e.target.selectionStart ?? next.length)
|