diff --git a/src/client/conversation-draft.ts b/src/client/conversation-draft.ts index f6c8fef..a565c38 100644 --- a/src/client/conversation-draft.ts +++ b/src/client/conversation-draft.ts @@ -14,6 +14,15 @@ * insert into the middle of a sentence keeps single-space separation like * the append path. An unknown/stale caret falls back to appending at the * end (the pre-fix behavior). + * + * The caret is also *restored* after the insert: committing a programmatic + * draft change resets the controlled textarea's caret (observed landing at + * the start of the value), which would make every later insert probe the + * reset position and drift the stack (A|B + C + D ended up as |DACB). The + * placement (`placeComposerCaretAfterInsert`) puts the caret right after the + * inserted text once the value commit lands — the index accounts for the + * separating space on the left, so stacked inserts stay at their running + * position (A|B + C + D → ACD|B). */ import type { Context, SidebarConversation } from '../context-types.ts' @@ -24,24 +33,62 @@ export interface DraftCaret { } /** - * Splice `text` into `draft` at `caret` (replacing any live selection) with - * whitespace-aware joins. `caret === null` (position unknown) appends at the - * end, exactly like the original behavior. Pure string math — unit-tested - * directly. + * The spliced draft plus the caret index (in that draft) right after the + * inserted text — the left-side separating space shifts the caret by one, + * which naive `start + text.length` misses (it would land before the tail). */ -export function insertAtCaret(draft: string, text: string, caret: DraftCaret | null): string { +interface SpliceResult { + draft: string + caretAfter: number +} + +/** + * Splice `text` into `draft` at `caret` (replacing any live selection) with + * whitespace-aware joins and report the caret position right after the + * inserted text. `caret === null` (position unknown) appends at the end, + * exactly like the original behavior. + */ +function spliceInsert(draft: string, text: string, caret: DraftCaret | null): SpliceResult { if (caret === null || draft === '') { - return draft.trim() === '' ? text : `${draft} ${text}` + const next = draft.trim() === '' ? text : `${draft} ${text}` + return { draft: next, caretAfter: next.length } } const prefix = draft.slice(0, caret.start) const suffix = draft.slice(caret.end) - if (prefix === '' && suffix === '') return text + if (prefix === '' && suffix === '') return { draft: text, caretAfter: text.length } // One separating space, but never doubled against adjacent whitespace // (or the string edges) — mirrors how typing in the middle of a sentence // behaves. const left = prefix === '' || /\s$/.test(prefix) ? '' : ' ' const right = suffix === '' || /^\s/.test(suffix) ? '' : ' ' - return `${prefix}${left}${text}${right}${suffix}` + return { + draft: `${prefix}${left}${text}${right}${suffix}`, + caretAfter: prefix.length + left.length + text.length, + } +} + +/** + * The spliced draft string (see {@link spliceInsert}); pure string math — + * unit-tested directly. + */ +export function insertAtCaret(draft: string, text: string, caret: DraftCaret | null): string { + return spliceInsert(draft, text, caret).draft +} + +/** + * Locate the composer `