fix(conversation): restore the composer caret after the draft insert (#425)
A programmatic setDraft commit resets the controlled textarea's caret (observed landing at the start of the value), so every later insert probed the reset position and the stack drifted (A|B + C + D became |DACB). placeComposerCaretAfterInsert puts the caret right after the inserted text once the value commit lands - the index accounts for the left-side separating space - and is guarded by a value-sync check plus a two-frame retry, so a newer edit or a different composer wins untouched. spliceInsert now reports the caret index together with the spliced draft so the placement math cannot drift from the join logic. Nothing else moves: the pure insertAtCaret surface is unchanged.
This commit is contained in:
@@ -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 `<textarea>` in the conversation column: prefer the
|
||||
* `data-phase`-tagged textarea (the composer's marker), falling back to any
|
||||
* textarea in the column, then to a bare data-phase textarea (older host
|
||||
* layouts without the column attribute). Null in jsdom-less hosts.
|
||||
*/
|
||||
function findComposerTextarea(): HTMLTextAreaElement | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
const column = document.querySelector('#root [data-slot="conversation"]')
|
||||
const find = (scope: ParentNode): HTMLTextAreaElement | null =>
|
||||
scope.querySelector('textarea[data-phase]') ?? scope.querySelector('textarea')
|
||||
return column !== null
|
||||
? find(column)
|
||||
: document.querySelector<HTMLTextAreaElement>('textarea[data-phase]')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,17 +103,7 @@ export function insertAtCaret(draft: string, text: string, caret: DraftCaret | n
|
||||
* hosts report null selectionStart/End).
|
||||
*/
|
||||
export function probeComposerCaret(draft: string): DraftCaret | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
// The active composer lives in the conversation column; prefer its
|
||||
// `data-phase`-tagged textarea (the composer's marker), falling back to
|
||||
// any textarea in the column, then to a bare data-phase textarea (older
|
||||
// host layouts without the column attribute).
|
||||
const column = document.querySelector('#root [data-slot="conversation"]')
|
||||
const find = (scope: ParentNode): HTMLTextAreaElement | null =>
|
||||
scope.querySelector('textarea[data-phase]') ?? scope.querySelector('textarea')
|
||||
const el = column !== null
|
||||
? find(column)
|
||||
: document.querySelector<HTMLTextAreaElement>('textarea[data-phase]')
|
||||
const el = findComposerTextarea()
|
||||
if (el === null || el.disabled || el.readOnly) return null
|
||||
if (el.value !== draft) return null
|
||||
let start = el.selectionStart
|
||||
@@ -78,6 +115,43 @@ export function probeComposerCaret(draft: string): DraftCaret | null {
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the composer caret to `caretIndex` after a programmatic
|
||||
* `setDraft` commit. A controlled textarea update resets the caret (React
|
||||
* commits the value asynchronously and the browser moves the caret to the
|
||||
* start/end), so the placement is scheduled and retried across at most two
|
||||
* animation frames (setTimeout fallback for jsdom), and only applied when
|
||||
* the textarea still matches `expectedDraft` — a newer edit or a different
|
||||
* composer wins the race untouched. The caret is clamped into the value
|
||||
* bounds, mirroring how browsers clamp type-in positions.
|
||||
*/
|
||||
export function placeComposerCaretAfterInsert(expectedDraft: string, caretIndex: number): void {
|
||||
let remaining = 2
|
||||
let scheduled = false
|
||||
const schedule = (fn: () => void): void => {
|
||||
if (scheduled) return
|
||||
scheduled = true
|
||||
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(fn)
|
||||
else setTimeout(fn, 0)
|
||||
}
|
||||
const place = (): void => {
|
||||
scheduled = false
|
||||
if (remaining <= 0) return
|
||||
remaining -= 1
|
||||
const el = findComposerTextarea()
|
||||
if (el === null || el.disabled || el.readOnly) return
|
||||
if (el.value !== expectedDraft) {
|
||||
// The commit has not landed yet (or a competing edit won) — one more
|
||||
// frame before giving up.
|
||||
schedule(place)
|
||||
return
|
||||
}
|
||||
const clamped = Math.max(0, Math.min(caretIndex, el.value.length))
|
||||
el.setSelectionRange(clamped, clamped)
|
||||
}
|
||||
schedule(place)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert `text` into the session's composer draft at the composer's live
|
||||
* caret (see {@link probeComposerCaret}), falling back to appending at the
|
||||
@@ -87,12 +161,24 @@ export function probeComposerCaret(draft: string): DraftCaret | null {
|
||||
export function appendToDraft(ctx: Context, sessionId: string, text: string): boolean {
|
||||
try {
|
||||
const actx = ctx.sessions.scope(sessionId)
|
||||
if (actx === undefined) return false
|
||||
if (actx === undefined) {
|
||||
console.warn('[dsh-better-sidebar] draft insert skipped: no session scope', sessionId)
|
||||
return false
|
||||
}
|
||||
const conversation = ctx.get('conversation') as SidebarConversation | undefined
|
||||
if (conversation === undefined) return false
|
||||
if (conversation === undefined) {
|
||||
console.warn('[dsh-better-sidebar] draft insert skipped: conversation service unavailable')
|
||||
return false
|
||||
}
|
||||
const input = conversation.input.for(actx)
|
||||
const draft = input.state.getSnapshot().draft
|
||||
input.setDraft(insertAtCaret(draft, text, probeComposerCaret(draft)))
|
||||
const caret = probeComposerCaret(draft)
|
||||
const { draft: next, caretAfter } = spliceInsert(draft, text, caret)
|
||||
input.setDraft(next)
|
||||
// Put the caret right after the inserted text once the value commit
|
||||
// lands — see the module doc for why this keeps stacked inserts at the
|
||||
// running position.
|
||||
placeComposerCaretAfterInsert(next, caretAfter)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('[dsh-better-sidebar] draft insert failed:', error)
|
||||
|
||||
Reference in New Issue
Block a user